package service import ( "context" "crypto/rand" "crypto/subtle" "database/sql" "encoding/base64" "errors" "log/slog" "net/http" "net/mail" "net/url" "strings" "time" "me-fit/template" "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" ) func HandleSignInPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user == nil { userComp := UserInfoComp(nil) signIn := auth.SignInOrUpComp(true) err := template.Layout(signIn, userComp).Render(r.Context(), w) if err != nil { utils.LogError("Failed to render sign in page", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } else if !user.EmailVerified { utils.DoRedirect(w, r, "/auth/verify") } else { utils.DoRedirect(w, r, "/") } } } func HandleSignUpPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user == nil { userComp := UserInfoComp(nil) signUpComp := auth.SignInOrUpComp(false) err := template.Layout(signUpComp, userComp).Render(r.Context(), w) if err != nil { utils.LogError("Failed to render sign up page", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } else if !user.EmailVerified { utils.DoRedirect(w, r, "/auth/verify") } else { utils.DoRedirect(w, r, "/") } } } func HandleSignUpVerifyPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user == nil { utils.DoRedirect(w, r, "/auth/signin") } else if user.EmailVerified { utils.DoRedirect(w, r, "/") } else { userComp := UserInfoComp(user) signIn := auth.VerifyComp() err := template.Layout(signIn, userComp).Render(r.Context(), w) if err != nil { utils.LogError("Failed to render verify page", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } } } func HandleDeleteAccountPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // An unverified email should be able to delete their account user := utils.GetUserFromSession(db, r) if user == nil { utils.DoRedirect(w, r, "/auth/signin") } else { userComp := UserInfoComp(user) comp := auth.DeleteAccountComp() err := template.Layout(comp, userComp).Render(r.Context(), w) if err != nil { utils.LogError("Failed to render delete account page", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } } } 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 HandleChangePasswordPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { isPasswordReset := r.URL.Query().Has("token") user := utils.GetUserFromSession(db, r) if user == nil && !isPasswordReset { utils.DoRedirect(w, r, "/auth/signin") } else { userComp := UserInfoComp(user) comp := auth.ChangePasswordComp(isPasswordReset) err := template.Layout(comp, userComp).Render(r.Context(), w) if err != nil { utils.LogError("Failed to render change password page", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } } } func HandleResetPasswordPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user != nil { utils.DoRedirect(w, r, "/auth/signin") } else { userComp := UserInfoComp(nil) comp := auth.ResetPasswordComp() err := template.Layout(comp, userComp).Render(r.Context(), w) if err != nil { utils.LogError("Failed to render change password page", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } } } func UserInfoComp(user *types.User) templ.Component { if user != nil { return auth.UserComp(user.Email) } else { return auth.UserComp("") } } func HandleSignUpComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var email = r.FormValue("email") var password = r.FormValue("password") _, err := mail.ParseAddress(email) if err != nil { http.Error(w, "Invalid email", http.StatusBadRequest) return } err = checkPassword(password) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } userId, err := uuid.NewRandom() if err != nil { utils.LogError("Could not generate UUID", err) auth.Error("Internal Server Error").Render(r.Context(), w) return } salt := make([]byte, 16) _, err = rand.Read(salt) if err != nil { utils.LogError("Could not generate salt", err) auth.Error("Internal Server Error").Render(r.Context(), w) return } hash := getHashPassword(password, salt) _, err = db.Exec("INSERT INTO user (user_uuid, email, email_verified, is_admin, password, salt, created_at) VALUES (?, ?, FALSE, FALSE, ?, ?, datetime())", userId, email, hash, salt) if err != nil { // This does leak information about the email being in use, though not specifically stated // It needs to be refacoteres to "If the email is not already in use, an email has been send to your address", or something // The happy path, currently a redirect, needs to send the same message! // Then it is also important to have the same compute time in both paths // Otherwise an attacker could guess emails when comparing the response time if strings.Contains(err.Error(), "email") { auth.Error("Bad Request").Render(r.Context(), w) return } utils.LogError("Could not insert user", err) auth.Error("Internal Server Error").Render(r.Context(), w) return } result := tryCreateSessionAndSetCookie(r, w, db, userId) if !result { return } // Send verification email as a goroutine go sendVerificationEmail(db, userId.String(), email) utils.DoRedirect(w, r, "/auth/verify") } } func HandleSignInComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var email = r.FormValue("email") var password = r.FormValue("password") var result bool = true start := time.Now() var ( userId uuid.UUID savedHash []byte salt []byte emailVerified bool ) err := db.QueryRow("SELECT user_uuid, password, salt, email_verified FROM user WHERE email = ?", email).Scan(&userId, &savedHash, &salt, &emailVerified) if err != nil { result = false } if result { new_hash := getHashPassword(password, salt) if subtle.ConstantTimeCompare(new_hash, savedHash) == 0 { result = false } } if result { result := tryCreateSessionAndSetCookie(r, w, db, userId) if !result { return } } duration := time.Since(start) timeToWait := 100 - duration.Milliseconds() // It is important to sleep for a while to prevent timing attacks // If the email is correct, the server will calculate the hash, which will take some time // This way an attacker could guess emails when comparing the response time // Because of that, we cant use WriteHeader in the middle of the function. We have to wait until the end // Unfortunatly this makes the code harder to read time.Sleep(time.Duration(timeToWait) * time.Millisecond) if result { if !emailVerified { utils.DoRedirect(w, r, "/auth/verify") } else { utils.DoRedirect(w, r, "/") } } else { auth.Error("Invalid email or password").Render(r.Context(), w) } } } func HandleSignOutComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user != nil { _, err := db.Exec("DELETE FROM session WHERE session_id = ?", user.SessionId) if err != nil { utils.LogError("Could not delete session", err) utils.TriggerToast(w, r, "error", "Internal Server Error") http.Error(w, err.Error(), http.StatusInternalServerError) return } } c := http.Cookie{ Name: "id", Value: "", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Path: "/", } http.SetCookie(w, &c) utils.DoRedirect(w, r, "/") } } func HandleDeleteAccountComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user == nil { utils.DoRedirect(w, r, "/auth/signin") return } password := r.FormValue("password") if password == "" { utils.TriggerToast(w, r, "error", "Password is required") return } var ( storedHash []byte salt []byte ) err := db.QueryRow("SELECT password, salt FROM user WHERE user_uuid = ?", user.Id).Scan(&storedHash, &salt) if err != nil { utils.LogError("Could not get password", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } currHash := getHashPassword(password, salt) if subtle.ConstantTimeCompare(currHash, storedHash) == 0 { utils.TriggerToast(w, r, "error", "Password is not correct") return } _, err = db.Exec("DELETE FROM workout WHERE user_id = ?", user.Id) if err != nil { utils.LogError("Could not delete workouts", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } _, err = db.Exec("DELETE FROM user_token WHERE user_uuid = ?", user.Id) if err != nil { utils.LogError("Could not delete user tokens", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } _, err = db.Exec("DELETE FROM session WHERE user_uuid = ?", user.Id) if err != nil { utils.LogError("Could not delete sessions", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } _, err = db.Exec("DELETE FROM user WHERE user_uuid = ?", user.Id) if err != nil { utils.LogError("Could not delete user", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } go utils.SendMail(user.Email, "Account deleted", "Your account has been deleted") utils.DoRedirect(w, r, "/") } } func HandleVerifyResendComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user == nil || user.EmailVerified { utils.DoRedirect(w, r, "/auth/signin") return } go sendVerificationEmail(db, user.Id.String(), user.Email) w.Write([]byte("
Verification email sent
")) } } func HandleChangePasswordComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := utils.GetUserFromSession(db, r) if user == nil { utils.DoRedirect(w, r, "/auth/signin") return } currPass := r.FormValue("current-password") newPass := r.FormValue("new-password") err := checkPassword(newPass) if err != nil { utils.TriggerToast(w, r, "error", err.Error()) return } if currPass == newPass { utils.TriggerToast(w, r, "error", "Please use a new password") return } var ( storedHash []byte salt []byte ) err = db.QueryRow("SELECT password, salt FROM user WHERE user_uuid = ?", user.Id).Scan(&storedHash, &salt) if err != nil { utils.LogError("Could not get password", err) utils.TriggerToast(w, r, "error", "Internal Server Error") return } currHash := getHashPassword(currPass, salt) if subtle.ConstantTimeCompare(currHash, storedHash) == 0 { utils.TriggerToast(w, r, "error", "Current Password is not correct") return } newHash := getHashPassword(newPass, salt) _, err = db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", newHash, user.Id) 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 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") err = checkPassword(newPass) if err != nil { utils.TriggerToast(w, r, "error", err.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) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") if email == "" { utils.TriggerToast(w, r, "error", "Please enter an email") return } var b []byte = make([]byte, 32) _, err := rand.Reader.Read(b) if err != nil { utils.LogError("Could not generate token", err) return } token := base64.StdEncoding.EncodeToString(b) 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(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 } utils.SendMail(email, "Reset Password", mail.String()) } utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent") } } func sendVerificationEmail(db *sql.DB, userId string, email string) { var token string err := db.QueryRow("SELECT token FROM user_token WHERE user_uuid = ? AND type = 'email_verify'", userId).Scan(&token) if err != nil && err != sql.ErrNoRows { utils.LogError("Could not get token", err) return } if token == "" { var b []byte = make([]byte, 32) _, err = rand.Reader.Read(b) if err != nil { utils.LogError("Could not generate token", err) return } token = base64.StdEncoding.EncodeToString(b) _, err = db.Exec("INSERT INTO user_token (user_uuid, type, token, created_at) VALUES (?, 'email_verify', ?, datetime())", userId, token) if err != nil { utils.LogError("Could not insert token", err) return } } var w strings.Builder err = tempMail.Register(token).Render(context.Background(), &w) if err != nil { utils.LogError("Could not render welcome email", err) return } utils.SendMail(email, "Welcome to ME-FIT", w.String()) } func tryCreateSessionAndSetCookie(r *http.Request, w http.ResponseWriter, db *sql.DB, user_uuid uuid.UUID) bool { var session_id_bytes []byte = make([]byte, 32) _, err := rand.Reader.Read(session_id_bytes) if err != nil { utils.LogError("Could not generate session ID", err) auth.Error("Internal Server Error").Render(r.Context(), w) return false } session_id := base64.StdEncoding.EncodeToString(session_id_bytes) // Delete old inactive sessions _, err = db.Exec("DELETE FROM session WHERE created_at < datetime('now','-8 hours') AND user_uuid = ?", user_uuid) if err != nil { utils.LogError("Could not delete old sessions", err) } _, err = db.Exec("INSERT INTO session (session_id, user_uuid, created_at) VALUES (?, ?, datetime())", session_id, user_uuid) if err != nil { utils.LogError("Could not insert session", err) auth.Error("Internal Server Error").Render(r.Context(), w) return false } cookie := http.Cookie{ Name: "id", Value: session_id, MaxAge: 60 * 60 * 8, // 8 hours Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Path: "/", } http.SetCookie(w, &cookie) return true } func getHashPassword(password string, salt []byte) []byte { return argon2.IDKey([]byte(password), salt, 1, 64*1024, 1, 16) } func checkPassword(password string) error { if len(password) < 8 || !strings.ContainsAny(password, "0123456789") || !strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") || !strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") || !strings.ContainsAny(password, "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?") { return errors.New("Password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character") } else { return nil } }