package service import ( "context" "crypto/subtle" "errors" "net/mail" "strings" "time" "me-fit/db" "me-fit/template/auth" mailTemplate "me-fit/template/mail" "me-fit/types" "me-fit/utils" "github.com/a-h/templ" "github.com/google/uuid" "golang.org/x/crypto/argon2" ) var ( ErrInvaidCredentials = 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") ) type User struct { Id uuid.UUID Email string EmailVerified bool } func NewUser(user *db.User) *User { return &User{ Id: user.Id, Email: user.Email, EmailVerified: user.EmailVerified, } } type Session struct { Id string CreatedAt time.Time User *User } func NewSession(session *db.Session, user *User) *Session { return &Session{ Id: session.Id, CreatedAt: session.CreatedAt, User: user, } } type AuthService interface { SignIn(email string, password string) (*Session, error) SignUp(email string, password string) (*User, error) SendVerificationMail(userId uuid.UUID, email string) VerifyUserEmail(token string) error SignOut(sessionId string) error DeleteAccount(user *User) error ChangePassword(user *User, currPass, newPass string) error ForgotPassword(email string) error ForgotPasswordResponse(token string, newPass string) error GetUserFromSessionId(sessionId string) (*User, error) } type AuthServiceImpl struct { dbAuth db.AuthDb randomGenerator RandomService clock ClockService mailService MailService serverSettings *types.ServerSettings } func NewAuthServiceImpl(dbAuth db.AuthDb, randomGenerator RandomService, clock ClockService, mailService MailService, serverSettings *types.ServerSettings) *AuthServiceImpl { return &AuthServiceImpl{ dbAuth: dbAuth, randomGenerator: randomGenerator, clock: clock, mailService: mailService, serverSettings: serverSettings, } } func (service AuthServiceImpl) SignIn(email string, password string) (*Session, error) { user, err := service.dbAuth.GetUserByEmail(email) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, ErrInvaidCredentials } else { return nil, types.ErrInternal } } hash := GetHashPassword(password, user.Salt) if subtle.ConstantTimeCompare(hash, user.Password) == 0 { return nil, ErrInvaidCredentials } session, err := service.createSession(user.Id) if err != nil { return nil, types.ErrInternal } return NewSession(session, NewUser(user)), nil } func (service AuthServiceImpl) createSession(userId uuid.UUID) (*db.Session, error) { sessionId, err := service.randomGenerator.String(32) if err != nil { return nil, types.ErrInternal } err = service.dbAuth.DeleteOldSessions(userId) if err != nil { return nil, types.ErrInternal } session := db.NewSession(sessionId, userId, service.clock.Now()) err = service.dbAuth.InsertSession(session) if err != nil { return nil, types.ErrInternal } return session, nil } func (service AuthServiceImpl) SignUp(email string, password string) (*User, error) { _, err := mail.ParseAddress(email) if err != nil { return nil, ErrInvalidEmail } if !isPasswordValid(password) { return nil, ErrInvalidPassword } userId, err := service.randomGenerator.UUID() if err != nil { return nil, types.ErrInternal } salt, err := service.randomGenerator.Bytes(16) if err != nil { return nil, types.ErrInternal } hash := GetHashPassword(password, salt) dbUser := db.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now()) err = service.dbAuth.InsertUser(dbUser) if err != nil { if err == db.ErrUserExists { return nil, ErrAccountExists } else { return nil, types.ErrInternal } } return NewUser(dbUser), nil } func (service AuthServiceImpl) SendVerificationMail(userId uuid.UUID, email string) { tokens, err := service.dbAuth.GetTokensByUserIdAndType(userId, db.TokenTypeEmailVerify) if err != nil { return } var token *db.Token if len(tokens) > 0 { token = tokens[0] } if token == nil { newTokenStr, err := service.randomGenerator.String(32) if err != nil { return } token = db.NewToken(userId, newTokenStr, db.TokenTypeEmailVerify, service.clock.Now(), service.clock.Now().Add(24*time.Hour)) err = service.dbAuth.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 { utils.LogError("Could not render welcome email", err) return } service.mailService.SendMail(email, "Welcome to ME-FIT", w.String()) } func (service AuthServiceImpl) VerifyUserEmail(tokenStr string) error { if tokenStr == "" { return types.ErrInternal } token, err := service.dbAuth.GetToken(tokenStr) if err != nil { return types.ErrInternal } user, err := service.dbAuth.GetUser(token.UserId) if err != nil { return types.ErrInternal } if token.Type != db.TokenTypeEmailVerify { return types.ErrInternal } now := service.clock.Now() if token.ExpiresAt.Before(now) { return types.ErrInternal } user.EmailVerified = true user.EmailVerifiedAt = &now err = service.dbAuth.UpdateUser(user) if err != nil { return types.ErrInternal } _ = service.dbAuth.DeleteToken(token.Token) return nil } func (service AuthServiceImpl) SignOut(sessionId string) error { return service.dbAuth.DeleteSession(sessionId) } func (service AuthServiceImpl) GetUserFromSessionId(sessionId string) (*User, error) { if sessionId == "" { return nil, ErrSessionIdInvalid } session, err := service.dbAuth.GetSession(sessionId) if err != nil { return nil, types.ErrInternal } user, err := service.dbAuth.GetUser(session.UserId) if err != nil { return nil, types.ErrInternal } if session.CreatedAt.Add(time.Duration(8 * time.Hour)).Before(service.clock.Now()) { return nil, nil } else { return NewUser(user), nil } } // TODO func UserInfoComp(user *User) templ.Component { if user != nil { return auth.UserComp(user.Email) } else { return auth.UserComp("") } } func (service AuthServiceImpl) DeleteAccount(user *User) error { err := service.dbAuth.DeleteUser(user.Id) if err != nil { return err } go service.mailService.SendMail(user.Email, "Account deleted", "Your account has been deleted") return nil } func (service AuthServiceImpl) ChangePassword(user *User, currPass, newPass string) error { if !isPasswordValid(newPass) { return ErrInvalidPassword } if currPass == newPass { return ErrInvalidPassword } _, err := service.SignIn(user.Email, currPass) if err != nil { return err } userDb, err := service.dbAuth.GetUser(user.Id) if err != nil { return err } newHash := GetHashPassword(newPass, userDb.Salt) userDb.Password = newHash err = service.dbAuth.UpdateUser(userDb) if err != nil { return err } return nil } func (service AuthServiceImpl) ForgotPassword(email string) error { tokenStr, err := service.randomGenerator.String(32) if err != nil { return err } user, err := service.dbAuth.GetUserByEmail(email) if err != nil { if err == db.ErrNotFound { return nil } else { return types.ErrInternal } } token := db.NewToken(user.Id, tokenStr, db.TokenTypePasswordReset, service.clock.Now(), service.clock.Now().Add(15*time.Minute)) err = service.dbAuth.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 { utils.LogError("Could not render reset password email", err) return types.ErrInternal } go service.mailService.SendMail(email, "Reset Password", mail.String()) return nil } func (service AuthServiceImpl) ForgotPasswordResponse(tokenStr string, newPass string) error { if !isPasswordValid(newPass) { return ErrInvalidPassword } token, err := service.dbAuth.GetToken(tokenStr) if err != nil { return err } err = service.dbAuth.DeleteToken(tokenStr) if err != nil { return err } user, err := service.dbAuth.GetUser(token.UserId) if err != nil { utils.LogError("Could not get user from token", err) return types.ErrInternal } passHash := GetHashPassword(newPass, user.Salt) user.Password = passHash err = service.dbAuth.UpdateUser(user) if err != nil { return err } return 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 } }