package service import ( "context" "crypto/subtle" "errors" "log/slog" "net/mail" "spend-sparrow/internal/db" mailTemplate "spend-sparrow/internal/template/mail" "spend-sparrow/internal/types" "strings" "time" "github.com/google/uuid" "golang.org/x/crypto/argon2" ) var ( ErrInvalidCredentials = errors.New("invalid email or password") ErrInvalidPassword = errors.New("the password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character") ErrInvalidEmail = errors.New("invalid email") ErrAccountExists = errors.New("account already exists") ErrSessionIdInvalid = errors.New("session ID is invalid") ErrTokenInvalid = errors.New("token is invalid") ) type Auth interface { SignUp(ctx context.Context, email string, password string) (*types.User, error) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) VerifyUserEmail(ctx context.Context, token string) error SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) SignInAnonymous(ctx context.Context) (*types.Session, error) SignOut(ctx context.Context, sessionId string) error DeleteAccount(ctx context.Context, user *types.User, currPass string) error ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error SendForgotPasswordMail(ctx context.Context, email string) error ForgotPassword(ctx context.Context, token string, newPass string) error IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool GetCsrfToken(ctx context.Context, session *types.Session) (string, error) CleanupSessionsAndTokens(ctx context.Context) error } type AuthImpl struct { db db.Auth random Random clock Clock mail Mail serverSettings *types.Settings } func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl { return &AuthImpl{ db: db, random: random, clock: clock, mail: mail, serverSettings: serverSettings, } } func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) { user, err := service.db.GetUserByEmail(ctx, email) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, nil, ErrInvalidCredentials } else { return nil, nil, types.ErrInternal } } hash := GetHashPassword(password, user.Salt) if subtle.ConstantTimeCompare(hash, user.Password) == 0 { return nil, nil, ErrInvalidCredentials } newSession, err := service.createSession(ctx, user.Id) if err != nil { return nil, nil, types.ErrInternal } err = service.db.DeleteSession(ctx, session.Id) if err != nil { return nil, nil, types.ErrInternal } tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf) if err != nil { return nil, nil, types.ErrInternal } for _, token := range tokens { err = service.db.DeleteToken(ctx, token.Token) if err != nil { return nil, nil, types.ErrInternal } } return newSession, user, nil } func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) { if sessionId == "" { return nil, nil, ErrSessionIdInvalid } session, err := service.db.GetSession(ctx, sessionId) if err != nil { return nil, nil, types.ErrInternal } if session.ExpiresAt.Before(service.clock.Now()) { _ = service.db.DeleteSession(ctx, sessionId) return nil, nil, nil } if session.UserId == uuid.Nil { return session, nil, nil } user, err := service.db.GetUser(ctx, session.UserId) if err != nil { return nil, nil, types.ErrInternal } return session, user, nil } func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, error) { session, err := service.createSession(ctx, uuid.Nil) if err != nil { return nil, types.ErrInternal } slog.Info("anonymous session created", "session-id", session.Id) return session, nil } func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) { _, err := mail.ParseAddress(email) if err != nil { return nil, ErrInvalidEmail } if !isPasswordValid(password) { return nil, ErrInvalidPassword } userId, err := service.random.UUID() if err != nil { return nil, types.ErrInternal } salt, err := service.random.Bytes(16) if err != nil { return nil, types.ErrInternal } hash := GetHashPassword(password, salt) user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now()) err = service.db.InsertUser(ctx, user) if err != nil { if errors.Is(err, db.ErrAlreadyExists) { return nil, ErrAccountExists } else { return nil, types.ErrInternal } } return user, nil } func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) { tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, types.TokenTypeEmailVerify) if err != nil && !errors.Is(err, db.ErrNotFound) { return } var token *types.Token if len(tokens) > 0 { token = tokens[0] } if token == nil { newTokenStr, err := service.random.String(32) if err != nil { return } token = types.NewToken( userId, "", newTokenStr, types.TokenTypeEmailVerify, service.clock.Now(), service.clock.Now().Add(24*time.Hour)) err = service.db.InsertToken(ctx, token) if err != nil { return } } var w strings.Builder err = mailTemplate.Register(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &w) if err != nil { slog.Error("Could not render welcome email", "err", err) return } service.mail.SendMail(email, "Welcome to spend-sparrow", w.String()) } func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error { if tokenStr == "" { return types.ErrInternal } token, err := service.db.GetToken(ctx, tokenStr) if err != nil { return types.ErrInternal } user, err := service.db.GetUser(ctx, token.UserId) if err != nil { return types.ErrInternal } if token.Type != types.TokenTypeEmailVerify { return types.ErrInternal } now := service.clock.Now() if token.ExpiresAt.Before(now) { return types.ErrInternal } user.EmailVerified = true user.EmailVerifiedAt = &now err = service.db.UpdateUser(ctx, user) if err != nil { return types.ErrInternal } _ = service.db.DeleteToken(ctx, token.Token) return nil } func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error { return service.db.DeleteSession(ctx, sessionId) } func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, currPass string) error { userDb, err := service.db.GetUser(ctx, user.Id) if err != nil { return types.ErrInternal } currHash := GetHashPassword(currPass, userDb.Salt) if subtle.ConstantTimeCompare(currHash, userDb.Password) == 0 { return ErrInvalidCredentials } err = service.db.DeleteUser(ctx, user.Id) if err != nil { return err } service.mail.SendMail(user.Email, "Account deleted", "Your account has been deleted") return nil } func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error { if !isPasswordValid(newPass) { return ErrInvalidPassword } if currPass == newPass { return ErrInvalidPassword } currHash := GetHashPassword(currPass, user.Salt) if subtle.ConstantTimeCompare(currHash, user.Password) == 0 { return ErrInvalidCredentials } newHash := GetHashPassword(newPass, user.Salt) user.Password = newHash err := service.db.UpdateUser(ctx, user) if err != nil { return err } sessions, err := service.db.GetSessions(ctx, user.Id) if err != nil { return types.ErrInternal } for _, s := range sessions { if s.Id != sessionId { err = service.db.DeleteSession(ctx, s.Id) if err != nil { return types.ErrInternal } } } return nil } func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string) error { tokenStr, err := service.random.String(32) if err != nil { return err } user, err := service.db.GetUserByEmail(ctx, email) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil } else { return types.ErrInternal } } token := types.NewToken( user.Id, "", tokenStr, types.TokenTypePasswordReset, service.clock.Now(), service.clock.Now().Add(15*time.Minute)) err = service.db.InsertToken(ctx, token) if err != nil { return types.ErrInternal } var mail strings.Builder err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail) if err != nil { slog.Error("Could not render reset password email", "err", err) return types.ErrInternal } service.mail.SendMail(email, "Reset Password", mail.String()) return nil } func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error { if !isPasswordValid(newPass) { return ErrInvalidPassword } token, err := service.db.GetToken(ctx, tokenStr) if err != nil { return ErrTokenInvalid } err = service.db.DeleteToken(ctx, tokenStr) if err != nil { return err } if token.Type != types.TokenTypePasswordReset || token.ExpiresAt.Before(service.clock.Now()) { return ErrTokenInvalid } user, err := service.db.GetUser(ctx, token.UserId) if err != nil { slog.Error("Could not get user from token", "err", err) return types.ErrInternal } passHash := GetHashPassword(newPass, user.Salt) user.Password = passHash err = service.db.UpdateUser(ctx, user) if err != nil { return err } sessions, err := service.db.GetSessions(ctx, user.Id) if err != nil { return types.ErrInternal } for _, session := range sessions { err = service.db.DeleteSession(ctx, session.Id) if err != nil { return types.ErrInternal } } return nil } func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool { token, err := service.db.GetToken(ctx, tokenStr) if err != nil { return false } if token.Type != types.TokenTypeCsrf || token.SessionId != sessionId || token.ExpiresAt.Before(service.clock.Now()) { return false } return true } func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session) (string, error) { if session == nil { return "", types.ErrInternal } tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf) if len(tokens) > 0 { return tokens[0].Token, nil } tokenStr, err := service.random.String(32) if err != nil { return "", types.ErrInternal } token := types.NewToken( session.UserId, session.Id, tokenStr, types.TokenTypeCsrf, service.clock.Now(), service.clock.Now().Add(8*time.Hour)) err = service.db.InsertToken(ctx, token) if err != nil { return "", types.ErrInternal } slog.Info("CSRF-Token created", "token", tokenStr) return tokenStr, nil } func (service AuthImpl) CleanupSessionsAndTokens(ctx context.Context) error { err := service.db.DeleteOldSessions(ctx) if err != nil { return types.ErrInternal } err = service.db.DeleteOldTokens(ctx) if err != nil { return types.ErrInternal } return nil } func (service AuthImpl) createSession(ctx context.Context, userId uuid.UUID) (*types.Session, error) { sessionId, err := service.random.String(32) if err != nil { return nil, types.ErrInternal } createAt := service.clock.Now() expiresAt := createAt.Add(24 * time.Hour) session := types.NewSession(sessionId, userId, createAt, expiresAt) err = service.db.InsertSession(ctx, session) if err != nil { return nil, types.ErrInternal } return session, nil } func GetHashPassword(password string, salt []byte) []byte { return argon2.IDKey([]byte(password), salt, 1, 64*1024, 1, 16) } func isPasswordValid(password string) bool { if len(password) < 8 || !strings.ContainsAny(password, "0123456789") || !strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") || !strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") || !strings.ContainsAny(password, "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?") { return false } else { return true } }