package service import ( "context" "crypto/subtle" "database/sql" "errors" "log/slog" "net/http" "net/mail" "net/url" "strings" "time" "me-fit/db" "me-fit/template/auth" tempMail "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 ServiceAuth interface { SignIn(email string, password string) (*Session, error) SignUp(email string, password string) (*User, error) SendVerificationMail(userId uuid.UUID, email string) SignOut(sessionId string) error DeleteAccount(user *User) error ChangePassword(user *User, currPass, newPass string) error GetUserFromSessionId(sessionId string) (*User, error) } type ServiceAuthImpl struct { dbAuth db.DbAuth randomGenerator RandomGenerator clock Clock mailService MailService serverSettings *types.ServerSettings } func NewServiceAuthImpl(dbAuth db.DbAuth, randomGenerator RandomGenerator, clock Clock, mailService MailService, serverSettings *types.ServerSettings) *ServiceAuthImpl { return &ServiceAuthImpl{ dbAuth: dbAuth, randomGenerator: randomGenerator, clock: clock, mailService: mailService, serverSettings: serverSettings, } } func (service ServiceAuthImpl) SignIn(email string, password string) (*Session, error) { user, err := service.dbAuth.GetUser(email) if err != nil { if errors.Is(err, db.ErrUserNotFound) { 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 ServiceAuthImpl) 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 nil, nil } func (service ServiceAuthImpl) 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 ServiceAuthImpl) SendVerificationMail(userId uuid.UUID, email string) { var token string token, err := service.dbAuth.GetEmailVerificationToken(userId) if err != nil { return } if token == "" { token, err := service.randomGenerator.String(32) if err != nil { return } err = service.dbAuth.InsertEmailVerificationToken(userId, token) if err != nil { return } } var w strings.Builder err = tempMail.Register(service.serverSettings.BaseUrl, 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 ServiceAuthImpl) SignOut(sessionId string) error { return service.dbAuth.DeleteSession(sessionId) } func (service ServiceAuthImpl) 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.GetUserById(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 HandleSignUpVerifyResponsePage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { utils.DoRedirect(w, r, "/auth/verify") return } result, err := db.Exec(` UPDATE user SET email_verified = true, email_verified_at = datetime() WHERE user_uuid = ( SELECT user_uuid FROM user_token WHERE type = "email_verify" AND token = ? ); `, token) if err != nil { utils.LogError("Could not update user on verify response", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } i, err := result.RowsAffected() if err != nil { utils.LogError("Could not get rows affected on verify response", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if i == 0 { utils.DoRedirect(w, r, "/") } else { utils.DoRedirect(w, r, "/auth/signin") } } } func UserInfoComp(user *User) templ.Component { if user != nil { return auth.UserComp(user.Email) } else { return auth.UserComp("") } } func (service ServiceAuthImpl) 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 ServiceAuthImpl) 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.GetUserById(user.Id) if err != nil { return err } newHash := GetHashPassword(newPass, userDb.Salt) err = service.dbAuth.UpdateUserPassword(user.Id, newHash) if err != nil { return err } return nil } func HandleActualResetPasswordComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL")) if err != nil { utils.LogError("Could not get current URL", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } token := pageUrl.Query().Get("token") if token == "" { utils.TriggerToast(w, r, "error", "No token") return } newPass := r.FormValue("new-password") if !isPasswordValid(newPass) { utils.TriggerToast(w, r, "error", ErrInvalidPassword.Error()) return } var ( userId uuid.UUID salt []byte ) err = db.QueryRow(` SELECT u.user_uuid, salt FROM user_token t INNER JOIN user u ON t.user_uuid = u.user_uuid WHERE t.token = ? AND t.type = 'password_reset' AND t.expires_at > datetime() `, token).Scan(&userId, &salt) if err != nil { slog.Warn("Could not get user from token: " + err.Error()) utils.TriggerToast(w, r, "error", "Invalid token") return } _, err = db.Exec("DELETE FROM user_token WHERE token = ? AND type = 'password_reset'", token) if err != nil { utils.LogError("Could not delete token", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } passHash := GetHashPassword(newPass, salt) _, err = db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", passHash, userId) if err != nil { utils.LogError("Could not update password", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } utils.TriggerToast(w, r, "success", "Password changed") } } func HandleResetPasswordComp(db *sql.DB, serverSettings *types.ServerSettings) http.HandlerFunc { mailService := NewMailServiceImpl(serverSettings) return func(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") if email == "" { utils.TriggerToast(w, r, "error", "Please enter an email") return } token, err := NewRandomGeneratorImpl().String(32) if err != nil { return } res, err := db.Exec(` INSERT INTO user_token (user_uuid, type, token, created_at, expires_at) SELECT user_uuid, 'password_reset', ?, datetime(), datetime('now', '+15 minute') FROM user WHERE email = ? `, token, email) if err != nil { utils.LogError("Could not insert token", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } i, err := res.RowsAffected() if err != nil { utils.LogError("Could not get rows affected", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } if i != 0 { var mail strings.Builder err = tempMail.ResetPassword(serverSettings.BaseUrl, token).Render(context.Background(), &mail) if err != nil { utils.LogError("Could not render reset password email", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } mailService.SendMail(email, "Reset Password", mail.String()) } utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent") } } 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 } }