package service import ( "context" "crypto/subtle" "errors" "net/mail" "strings" "time" "me-fit/db" "me-fit/log" mailTemplate "me-fit/template/mail" "me-fit/types" "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 ExpiresAt time.Time User *User } func NewSession(session *db.Session, user *User) *Session { return &Session{ Id: session.Id, CreatedAt: session.CreatedAt, ExpiresAt: session.ExpiresAt, User: user, } } type Auth interface { SignUp(email string, password string) (*User, error) SendVerificationMail(userId uuid.UUID, email string) VerifyUserEmail(token string) error SignIn(email string, password string) (*Session, error) SignInSession(sessionId string) (*Session, error) SignInAnonymous() (*Session, error) SignOut(sessionId string) error DeleteAccount(user *User) error ChangePassword(user *User, currPass, newPass string) error SendForgotPasswordMail(email string) error ForgotPassword(token string, newPass string) error IsCsrfTokenValid(tokenStr string, sessionId string) bool GetCsrfToken(session *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(email string, password string) (*Session, error) { user, err := service.db.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 AuthImpl) SignInSession(sessionId string) (*Session, error) { if sessionId == "" { return nil, ErrSessionIdInvalid } sessionDb, err := service.db.GetSession(sessionId) if err != nil { return nil, types.ErrInternal } if sessionDb.ExpiresAt.After(service.clock.Now()) { return nil, nil } userDb, err := service.db.GetUser(sessionDb.UserId) if err != nil { return nil, types.ErrInternal } user := NewUser(userDb) session := NewSession(sessionDb, user) return session, nil } func (service AuthImpl) SignInAnonymous() (*Session, error) { sessionDb, err := service.createSession(uuid.Nil) if err != nil { return nil, types.ErrInternal } return NewSession(sessionDb, nil), nil } func (service AuthImpl) createSession(userId uuid.UUID) (*db.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 := db.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) (*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) dbUser := db.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now()) err = service.db.InsertUser(dbUser) if err != nil { if err == db.ErrAlreadyExists { return nil, ErrAccountExists } else { return nil, types.ErrInternal } } return NewUser(dbUser), nil } func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) { tokens, err := service.db.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.random.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.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 != 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.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 *User) error { err := service.db.DeleteUser(user.Id) if err != nil { return err } go service.mail.SendMail(user.Email, "Account deleted", "Your account has been deleted") return nil } func (service AuthImpl) 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.db.GetUser(user.Id) if err != nil { return err } newHash := GetHashPassword(newPass, userDb.Salt) userDb.Password = newHash err = service.db.UpdateUser(userDb) if err != nil { return err } 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 := db.NewToken(user.Id, "", tokenStr, db.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 } go 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 err } err = service.db.DeleteToken(tokenStr) if err != nil { return err } 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 } 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 != db.TokenTypeCsrf || token.SessionId != sessionId || token.ExpiresAt.Before(service.clock.Now()) { return false } return true } func (service AuthImpl) GetCsrfToken(session *Session) (string, error) { if session == nil { return "", types.ErrInternal } tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, db.TokenTypeCsrf) if len(tokens) > 0 { return tokens[0].Token, nil } tokenStr, err := service.random.String(32) if err != nil { return "", types.ErrInternal } token := db.NewToken(uuid.Nil, session.Id, tokenStr, db.TokenTypeCsrf, service.clock.Now(), service.clock.Now().Add(24*time.Hour)) err = service.db.InsertToken(token) if err != nil { return "", types.ErrInternal } 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 } }