package service import ( "context" "crypto/subtle" "errors" "net/mail" "strings" "time" "web-app-template/db" "web-app-template/log" mailTemplate "web-app-template/template/mail" "web-app-template/types" "github.com/google/uuid" "golang.org/x/crypto/argon2" ) var ( ErrInvalidCredentials = errors.New("invalid email or password") ErrInvalidPassword = errors.New("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(email string, password string) (*types.User, error) SendVerificationMail(userId uuid.UUID, email string) VerifyUserEmail(token string) error SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error) SignInSession(sessionId string) (*types.Session, *types.User, error) SignInAnonymous() (*types.Session, error) SignOut(sessionId string) error DeleteAccount(user *types.User, currPass string) error ChangePassword(user *types.User, sessionId string, currPass, newPass string) error SendForgotPasswordMail(email string) error ForgotPassword(token string, newPass string) error IsCsrfTokenValid(tokenStr string, sessionId string) bool GetCsrfToken(session *types.Session) (string, error) } type AuthImpl struct { db db.Auth random Random clock Clock mail Mail serverSettings *types.Settings } func NewAuthImpl(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(session *types.Session, email string, password string) (*types.Session, *types.User, error) { user, err := service.db.GetUserByEmail(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 } err = service.cleanUpSessionWithTokens(session) if err != nil { return nil, nil, types.ErrInternal } session, err = service.createSession(user.Id) if err != nil { return nil, nil, types.ErrInternal } return session, user, nil } func (service AuthImpl) cleanUpSessionWithTokens(session *types.Session) error { if session == nil { return nil } err := service.db.DeleteSession(session.Id) if err != nil { return types.ErrInternal } tokens, err := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf) if err != nil { return types.ErrInternal } for _, token := range tokens { err = service.db.DeleteToken(token.Token) if err != nil { return types.ErrInternal } } return nil } func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.User, error) { if sessionId == "" { return nil, nil, ErrSessionIdInvalid } session, err := service.db.GetSession(sessionId) if err != nil { return nil, nil, types.ErrInternal } if session.ExpiresAt.Before(service.clock.Now()) { _ = service.db.DeleteSession(sessionId) return nil, nil, nil } if session.UserId == uuid.Nil { return session, nil, nil } user, err := service.db.GetUser(session.UserId) if err != nil { return nil, nil, types.ErrInternal } return session, user, nil } func (service AuthImpl) SignInAnonymous() (*types.Session, error) { session, err := service.createSession(uuid.Nil) if err != nil { return nil, types.ErrInternal } log.Info("Anonymous session created: %v", session.Id) return session, nil } func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error) { sessionId, err := service.random.String(32) if err != nil { return nil, types.ErrInternal } err = service.db.DeleteOldSessions(userId) 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(session) if err != nil { return nil, types.ErrInternal } return session, nil } func (service AuthImpl) SignUp(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(user) if err != nil { if err == db.ErrAlreadyExists { return nil, ErrAccountExists } else { return nil, types.ErrInternal } } return user, nil } func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) { tokens, err := service.db.GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify) if err != nil && 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(token) if err != nil { return } } var w strings.Builder err = mailTemplate.Register(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &w) if err != nil { log.Error("Could not render welcome email: %v", err) return } service.mail.SendMail(email, "Welcome to ME-FIT", w.String()) } func (service AuthImpl) VerifyUserEmail(tokenStr string) error { if tokenStr == "" { return types.ErrInternal } token, err := service.db.GetToken(tokenStr) if err != nil { return types.ErrInternal } user, err := service.db.GetUser(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(user) if err != nil { return types.ErrInternal } _ = service.db.DeleteToken(token.Token) return nil } func (service AuthImpl) SignOut(sessionId string) error { return service.db.DeleteSession(sessionId) } func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error { userDb, err := service.db.GetUser(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(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(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(user) if err != nil { return err } sessions, err := service.db.GetSessions(user.Id) if err != nil { return types.ErrInternal } for _, s := range sessions { if s.Id != sessionId { err = service.db.DeleteSession(s.Id) if err != nil { return types.ErrInternal } } } return nil } func (service AuthImpl) SendForgotPasswordMail(email string) error { tokenStr, err := service.random.String(32) if err != nil { return err } user, err := service.db.GetUserByEmail(email) if err != nil { if 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(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 { log.Error("Could not render reset password email: %v", err) return types.ErrInternal } service.mail.SendMail(email, "Reset Password", mail.String()) return nil } func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error { if !isPasswordValid(newPass) { return ErrInvalidPassword } token, err := service.db.GetToken(tokenStr) if err != nil { return ErrTokenInvalid } err = service.db.DeleteToken(tokenStr) if err != nil { return err } if token.Type != types.TokenTypePasswordReset || token.ExpiresAt.Before(service.clock.Now()) { return ErrTokenInvalid } user, err := service.db.GetUser(token.UserId) if err != nil { log.Error("Could not get user from token: %v", err) return types.ErrInternal } passHash := GetHashPassword(newPass, user.Salt) user.Password = passHash err = service.db.UpdateUser(user) if err != nil { return err } sessions, err := service.db.GetSessions(user.Id) if err != nil { return types.ErrInternal } for _, session := range sessions { err = service.db.DeleteSession(session.Id) if err != nil { return types.ErrInternal } } return nil } func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool { token, err := service.db.GetToken(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(session *types.Session) (string, error) { if session == nil { return "", types.ErrInternal } tokens, _ := service.db.GetTokensBySessionIdAndType(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(token) if err != nil { return "", types.ErrInternal } log.Info("CSRF-Token created: %v", tokenStr) return tokenStr, 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 } }