diff --git a/db/auth.go b/db/auth.go new file mode 100644 index 0000000..eb609de --- /dev/null +++ b/db/auth.go @@ -0,0 +1,29 @@ +package db + +import ( + "database/sql" + "errors" + "strings" + + "github.com/google/uuid" +) + +type AuthDb interface { + InsertUser(userIs uuid.UUID, email string, password string) error +} +type AuthDbSqlite struct { + db *sql.DB +} + +func (a *AuthDbSqlite) InsertUser(userId uuid.UUID, email string, passwordHash []byte, salt []byte) error { + + _, err := a.db.Exec("INSERT INTO user (user_uuid, email, email_verified, is_admin, password, salt, created_at) VALUES (?, ?, FALSE, FALSE, ?, ?, datetime())", userId, email, passwordHash, salt) + if err != nil { + if strings.Contains(err.Error(), "email") { + return errors.New("Bad Request") + } + + utils.LogError("Could not insert user", err) + return ErrInternalServer + } +} diff --git a/handler/auth.go b/handler/auth.go index 9bb57cb..16b5af0 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -1,27 +1,375 @@ package handler import ( + "context" "me-fit/service" + "me-fit/template" + "me-fit/template/auth" + mail "me-fit/template/mail" + "me-fit/types" + "me-fit/utils" + "strings" "database/sql" "net/http" + "net/url" ) -func handleAuth(db *sql.DB, router *http.ServeMux) { - // Don't use auth middleware for these routes, as it makes redirecting very difficult, if the mail is not yet verified - router.Handle("/auth/signin", service.HandleSignInPage(db)) - router.Handle("/auth/signup", service.HandleSignUpPage(db)) - router.Handle("/auth/verify", service.HandleSignUpVerifyPage(db)) // Hint for the user to verify their email - router.Handle("/auth/delete-account", service.HandleDeleteAccountPage(db)) - router.Handle("/auth/verify-email", service.HandleSignUpVerifyResponsePage(db)) // The link contained in the email - router.Handle("/auth/change-password", service.HandleChangePasswordPage(db)) - router.Handle("/auth/reset-password", service.HandleResetPasswordPage(db)) - router.Handle("/api/auth/signup", service.HandleSignUpComp(db)) - router.Handle("/api/auth/signin", service.HandleSignInComp(db)) - router.Handle("/api/auth/signout", service.HandleSignOutComp(db)) - router.Handle("/api/auth/delete-account", service.HandleDeleteAccountComp(db)) - router.Handle("/api/auth/verify-resend", service.HandleVerifyResendComp(db)) - router.Handle("/api/auth/change-password", service.HandleChangePasswordComp(db)) - router.Handle("/api/auth/reset-password", service.HandleResetPasswordComp(db)) - router.Handle("/api/auth/reset-password-actual", service.HandleActualResetPasswordComp(db)) +type AuthHandler struct { + db *sql.DB + service *service.AuthService +} + +func (a *AuthHandler) authUi() http.Handler { + router := http.NewServeMux() + + router.Handle("/auth/signin", handleSignInPage(a.db)) + router.Handle("/auth/signup", handleSignUpPage(a.db)) + router.Handle("/auth/verify", handleSignUpVerifyPage(a.db)) // Hint for the user to verify their email + router.Handle("/auth/delete-account", handleDeleteAccountPage(a.db)) + router.Handle("/auth/verify-email", handleSignUpVerifyResponsePage(a.db)) // The link contained in the email + router.Handle("/auth/change-password", handleChangePasswordPage(a.db)) + router.Handle("/auth/reset-password", handleResetPasswordPage(a.db)) + router.Handle("/", handleNotFound(a.db)) + + return router +} + +func (a *AuthHandler) authApi() http.Handler { + router := http.NewServeMux() + + router.Handle("/api/auth/signup", handleSignUp(a.db)) + router.Handle("/api/auth/signin", a.handleSignIn()) + router.Handle("/api/auth/signout", handleSignOut(a.db)) + router.Handle("/api/auth/delete-account", handleDeleteAccount(a.db)) + router.Handle("/api/auth/verify-resend", handleVerifyResend(a.db)) + router.Handle("/api/auth/change-password", handleChangePassword(a.db)) + router.Handle("/api/auth/reset-password", handleResetPassword(a.db)) + router.Handle("/api/auth/reset-password-actual", handleActualResetPassword(a.db)) + + return router +} + +func handleSignInPage(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := service.GetUserFromRequest(db, r) + + if user == nil { + userComp := service.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 := service.GetUserFromRequest(db, r) + + if user == nil { + userComp := service.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 := service.GetUserFromRequest(db, r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + } else if user.EmailVerified { + utils.DoRedirect(w, r, "/") + } else { + userComp := service.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 := service.GetUserFromRequest(db, r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + } else { + userComp := service.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 + } + + err := service.ValidateEmail(db, token) + if err != nil { + utils.LogError("Could not validate email", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + utils.DoRedirect(w, r, "/") + } +} + +func handleChangePasswordPage(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + isPasswordReset := r.URL.Query().Has("token") + + user := service.GetUserFromRequest(db, r) + if user == nil && !isPasswordReset { + utils.DoRedirect(w, r, "/auth/signin") + } else { + userComp := service.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 := service.GetUserFromRequest(db, r) + if user != nil { + utils.DoRedirect(w, r, "/auth/signin") + } else { + userComp := service.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 handleSignUp(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var email = r.FormValue("email") + var password = r.FormValue("password") + + sessionId, err := service.SignUp(db, email, password) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + http.SetCookie(w, createSessionCookie(*sessionId)) + + utils.DoRedirect(w, r, "/auth/verify") + } +} + +func createSessionCookie(sessionId types.SessionId) *http.Cookie { + cookie := http.Cookie{ + Name: "id", + Value: string(sessionId), + MaxAge: 60 * 60 * 8, // 8 hours + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + } + + return &cookie +} + +func (a *AuthHandler) handleSignIn() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var email = r.FormValue("email") + var password = r.FormValue("password") + + sessionId, err := a.service.SignIn(email, password) + if err != nil { + utils.TriggerToast(w, r, "error", err.Error()) + return + } + + http.SetCookie(w, createSessionCookie(*sessionId)) + + utils.DoRedirect(w, r, "/auth/verify") + } +} + +func handleSignOut(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := service.GetUserFromRequest(db, r) + + err := service.SignOut(db, user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + utils.TriggerToast(w, r, "error", "Internal Server Error") + 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 handleDeleteAccount(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := service.GetUserFromRequest(db, r) + + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + password := r.FormValue("password") + + err := service.DeleteAccount(db, user, password) + if err != nil { + utils.LogError("Could not delete account", err) + utils.TriggerToast(w, r, "error", err.Error()) + } + + utils.DoRedirect(w, r, "/") + } +} + +func handleVerifyResend(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := service.GetUserFromRequest(db, r) + if user == nil || user.EmailVerified { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + go service.SendVerificationEmail(db, user.Id.String(), user.Email) + + w.Write([]byte("

Verification email sent

")) + } +} + +func handleChangePassword(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + user := service.GetUserFromRequest(db, r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + currPass := r.FormValue("current-password") + newPass := r.FormValue("new-password") + + err := service.ChangePassword(db, user, currPass, newPass) + if err != nil { + utils.TriggerToast(w, r, "error", err.Error()) + return + } + + utils.TriggerToast(w, r, "success", "Password changed") + } +} + +func handleResetPassword(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + email := r.FormValue("email") + + token, err := service.ResetPassword(db, email) + if err != nil { + utils.TriggerToast(w, r, "error", err.Error()) + return + } + + if token != "" { + var string strings.Builder + err = mail.ResetPassword(token).Render(context.Background(), &string) + 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", string.String()) + } + + utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent") + } +} + +func handleActualResetPassword(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") + + service.ActualResetPassword(db, token, newPass) + if err != nil { + utils.TriggerToast(w, r, "error", err.Error()) + return + } + + utils.TriggerToast(w, r, "success", "Password changed") + } } diff --git a/handler/default.go b/handler/default.go index e877d59..083582c 100644 --- a/handler/default.go +++ b/handler/default.go @@ -3,26 +3,69 @@ package handler import ( "me-fit/middleware" "me-fit/service" + "me-fit/template" + "me-fit/utils" "database/sql" "net/http" ) func GetHandler(db *sql.DB) http.Handler { - var router = http.NewServeMux() + router := http.NewServeMux() - router.HandleFunc("/", service.HandleIndexAnd404(db)) + router.HandleFunc("/{$}", handleIndex(db)) + router.HandleFunc("/", handleNotFound(db)) - // Serve static files (CSS, JS and images) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) - handleWorkout(db, router) + authHandler := AuthHandler{ + db: db, + service: service.NewAuthService(db), + } - handleAuth(db, router) + router.Handle("/auth/", authHandler.authUi()) + router.Handle("/api/auth/", authHandler.authApi()) - return middleware.Logging(middleware.EnableCors(router)) + router.Handle("/workout", authMiddleware(db, workoutUi(db))) + router.Handle("/api/workout", authMiddleware(db, workoutApi(db))) + // Needed a second time with trailing slash, otherwise either /api/workout or /api/workout/{id} does not match + router.Handle("/api/workout/", authMiddleware(db, workoutApi(db))) + + return middleware.Logging( + middleware.EnableCors( + router)) } -func auth(db *sql.DB, h http.Handler) http.Handler { +func authMiddleware(db *sql.DB, h http.Handler) http.Handler { return middleware.EnsureValidSession(db, h) } + +func handleIndex(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := service.GetUserFromRequest(db, r) + + err := template. + Layout(template.Index(), service.UserInfoComp(user)). + Render(r.Context(), w) + if err != nil { + utils.LogError("Failed to render index", err) + http.Error(w, "Failed to render index", http.StatusInternalServerError) + } + } +} + +func handleNotFound(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := service.GetUserFromRequest(db, r) + + err := template. + Layout(template.NotFound(), service.UserInfoComp(user)). + Render(r.Context(), w) + if err != nil { + utils.LogError("Failed to render index", err) + http.Error(w, "Failed to render index", http.StatusInternalServerError) + } + + w.WriteHeader(http.StatusNotFound) + } +} diff --git a/handler/workout.go b/handler/workout.go index d78212c..ed3672e 100644 --- a/handler/workout.go +++ b/handler/workout.go @@ -7,9 +7,20 @@ import ( "net/http" ) -func handleWorkout(db *sql.DB, router *http.ServeMux) { - router.Handle("/workout", auth(db, service.HandleWorkoutPage(db))) - router.Handle("POST /api/workout", auth(db, service.HandleWorkoutNewComp(db))) - router.Handle("GET /api/workout", auth(db, service.HandleWorkoutGetComp(db))) - router.Handle("DELETE /api/workout/{id}", auth(db, service.HandleWorkoutDeleteComp(db))) +func workoutUi(db *sql.DB) http.Handler { + router := http.NewServeMux() + + router.Handle("/workout", service.HandleWorkoutPage(db)) + + return router +} + +func workoutApi(db *sql.DB) http.Handler { + router := http.NewServeMux() + + router.Handle("POST /api/workout", service.HandleWorkoutNewComp(db)) + router.Handle("GET /api/workout", service.HandleWorkoutGetComp(db)) + router.Handle("DELETE /api/workout/{id}", service.HandleWorkoutDeleteComp(db)) + + return router } diff --git a/middleware/auth.go b/middleware/auth.go index e48c010..4c7786a 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "me-fit/service" "me-fit/utils" "context" @@ -12,7 +13,7 @@ func EnsureValidSession(db *sql.DB, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := utils.GetUserFromSession(db, r) + user := service.GetUserFromRequest(db, r) if user == nil { utils.DoRedirect(w, r, "/auth/signin") return diff --git a/service/auth.go b/service/auth.go index 024ec09..13b049c 100644 --- a/service/auth.go +++ b/service/auth.go @@ -5,15 +5,15 @@ import ( "crypto/rand" "crypto/subtle" "database/sql" + "encoding/base64" "errors" "log/slog" "net/http" "net/mail" - "net/url" "strings" "time" - "me-fit/template" + "me-fit/db" "me-fit/template/auth" tempMail "me-fit/template/mail" "me-fit/types" @@ -24,165 +24,178 @@ import ( "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) +// TESTED - if user == nil { - userComp := UserInfoComp(nil) - signIn := auth.SignInOrUpComp(true) - err := template.Layout(signIn, userComp).Render(r.Context(), w) +var ( + ErrInternalServer = errors.New("Internal Server Error") + ErrInvalidUsernameOrPassword = errors.New("Invalid username or password") +) - if err != nil { - utils.LogError("Failed to render sign in page", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } +// NOT TESTED - } else if !user.EmailVerified { - utils.DoRedirect(w, r, "/auth/verify") - } else { - utils.DoRedirect(w, r, "/") - } +type AuthService struct { + db *sql.DB + dbSer *db.AuthDb +} + +func NewAuthService(db *sql.DB) *AuthService { + return &AuthService{ + db: db, } } -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 (a *AuthService) SignUp(email string, password string) (*types.SessionId, error) { + _, err := mail.ParseAddress(email) + if err != nil { + return nil, errors.Join(errors.New("Invalid email")) } + + err = checkPassword(password) + if err != nil { + return nil, err + } + + userId, err := uuid.NewRandom() + if err != nil { + utils.LogError("Could not generate UUID for new user", err) + return nil, ErrInternalServer + } + + salt := make([]byte, 16) + _, err = rand.Read(salt) + if err != nil { + utils.LogError("Could not generate salt for new user", err) + return nil, ErrInternalServer + } + + hash := getHashPassword(password, salt) + + err := a.dbSer.InsertUser(a.db, userId, email, hash, salt) + _, err = a.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") { + return nil, errors.New("Bad Request") + } + + utils.LogError("Could not insert user", err) + return nil, ErrInternalServer + } + + sessionId, err := createSession(a.db, userId) + if err != nil { + return nil, err + } + + // Send verification email as a goroutine + go SendVerificationEmail(a.db, userId.String(), email) + return sessionId, nil } -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 (a *AuthService) SignIn(email string, password string) (*types.SessionId, error) { + start := time.Now() + + sessionId, err := a.internalSignIn(email, password) + + 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) + + return sessionId, err } -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 (a *AuthService) internalSignIn(email string, password string) (*types.SessionId, error) { + var ( + userId uuid.UUID + savedHash []byte + salt []byte + ) + + err := a.db.QueryRow("SELECT user_uuid, password, salt FROM user WHERE email = ?", email).Scan(&userId, &savedHash, &salt) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrInvalidUsernameOrPassword } + utils.LogError("Could not query user on sign in", err) + return nil, ErrInternalServer } + + new_hash := getHashPassword(password, salt) + + if subtle.ConstantTimeCompare(new_hash, savedHash) == 0 { + return nil, errors.Join(ErrInvalidUsernameOrPassword) + } + + sessionId, err := createSession(a.db, userId) + if err != nil { + return nil, err + } + + return sessionId, nil } -func HandleSignUpVerifyResponsePage(db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func GetUserFromSessionId(db *sql.DB, sessionId types.SessionId) *types.User { + if sessionId == "" { + return nil + } - token := r.URL.Query().Get("token") + var ( + createdAt time.Time + userId uuid.UUID + email string + emailVerified bool + ) - if token == "" { - utils.DoRedirect(w, r, "/auth/verify") - return - } + err := db.QueryRow(` + SELECT u.user_uuid, u.email, u.email_verified, s.created_at + FROM session s + INNER JOIN user u ON s.user_uuid = u.user_uuid + WHERE session_id = ?`, sessionId).Scan(&userId, &email, &emailVerified, &createdAt) + if err != nil { + slog.Warn("Could not verify session: " + err.Error()) + return nil + } - 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") - } + if createdAt.Add(time.Duration(8 * time.Hour)).Before(time.Now()) { + return nil + } else { + return types.NewUser(userId, email, sessionId, emailVerified) } } +func ValidateEmail(db *sql.DB, token string) error { + 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) -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) - } - } + if err != nil { + return errors.Join(errors.New("Could not update user on verify response"), err) } -} -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) - } - } + i, err := result.RowsAffected() + if err != nil { + return errors.Join(errors.New("Could not get rows affected on verify response"), err) } + + if i == 0 { + return errors.New("Token is invalid") + } + + return nil } func UserInfoComp(user *types.User) templ.Component { @@ -194,317 +207,113 @@ func UserInfoComp(user *types.User) templ.Component { } } -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 SignOut(db *sql.DB, user *types.User) error { + if user == nil { + return nil } + + _, err := db.Exec("DELETE FROM session WHERE session_id = ?", user.SessionId) + if err != nil { + return errors.Join(errors.New("Could not delete session"), err) + } + + return nil } -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") +func DeleteAccount(db *sql.DB, user *types.User, password string) error { - 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) - } + if password == "" { + return errors.New("Please enter your password") } + + var ( + storedHash []byte + salt []byte + ) + + err := db.QueryRow("SELECT password, salt FROM user WHERE user_uuid = ?", user.Id).Scan(&storedHash, &salt) + if err != nil { + return errors.Join(errors.New("Could not get password"), err) + } + + currHash := getHashPassword(password, salt) + if subtle.ConstantTimeCompare(currHash, storedHash) == 0 { + return errors.New("Password is not correct") + } + + _, err = db.Exec("DELETE FROM workout WHERE user_id = ?", user.Id) + if err != nil { + return errors.Join(errors.New("Could not delete workouts"), err) + } + + _, err = db.Exec("DELETE FROM user_token WHERE user_uuid = ?", user.Id) + if err != nil { + return errors.Join(errors.New("Could not delete tokens"), err) + } + + _, err = db.Exec("DELETE FROM session WHERE user_uuid = ?", user.Id) + if err != nil { + return errors.Join(errors.New("Could not delete sessions"), err) + } + + _, err = db.Exec("DELETE FROM user WHERE user_uuid = ?", user.Id) + if err != nil { + return errors.Join(errors.New("Could not delete user"), err) + } + + go utils.SendMail(user.Email, "Account deleted", "Your account has been deleted") + return nil } -func HandleSignOutComp(db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - user := utils.GetUserFromSession(db, r) +func ChangePassword(db *sql.DB, user *types.User, currPass string, newPass string) error { - 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, "/") + err := checkPassword(newPass) + if err != nil { + return err } + + if currPass == newPass { + return errors.New("New password can not be the same as the current password") + } + + var ( + storedHash []byte + salt []byte + ) + + err = db.QueryRow("SELECT password, salt FROM user WHERE user_uuid = ?", user.Id).Scan(&storedHash, &salt) + if err != nil { + return errors.Join(errors.New("Could not get password"), err) + } + + currHash := getHashPassword(currPass, salt) + if subtle.ConstantTimeCompare(currHash, storedHash) == 0 { + return errors.New("Current password is not correct") + } + + newHash := getHashPassword(newPass, salt) + + _, err = db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", newHash, user.Id) + if err != nil { + return errors.Join(errors.New("Could not update password"), err) + } + + return nil } -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 - } +func ActualResetPassword(db *sql.DB, token string, newPass string) error { - 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, "/") + err := checkPassword(newPass) + if err != nil { + return err } -} -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 - } + var ( + userId uuid.UUID + salt []byte + ) - 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(` + err = db.QueryRow(` SELECT u.user_uuid, salt FROM user_token t INNER JOIN user u ON t.user_uuid = u.user_uuid @@ -512,80 +321,59 @@ func HandleActualResetPasswordComp(db *sql.DB) http.HandlerFunc { 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") + if err != nil { + return errors.Join(errors.New("Could not get user from token"), err) } + + _, err = db.Exec("DELETE FROM user_token WHERE token = ? AND type = 'password_reset'", token) + if err != nil { + return errors.Join(errors.New("Could not delete token"), err) + } + + passHash := getHashPassword(newPass, salt) + + _, err = db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", passHash, userId) + if err != nil { + return errors.Join(errors.New("Could not update password"), err) + } + + return nil } -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 - } +func ResetPassword(db *sql.DB, email string) (string, error) { - token, err := utils.RandomToken() - if err != nil { - utils.LogError("Could not generate token", err) - return - } + if email == "" { + return "", errors.New("Please enter an email") + } - res, err := db.Exec(` + token, err := randomToken() + if err != nil { + return "", errors.Join(errors.New("Could not generate token"), err) + } + + 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 - } + if err != nil { + return "", errors.Join(errors.New("Could not insert token"), err) + } - i, err := res.RowsAffected() - if err != nil { - utils.LogError("Could not get rows affected", err) - utils.TriggerToast(w, r, "error", "Internal Server Error") - return - } + i, err := res.RowsAffected() + if err != nil { + return "", errors.Join(errors.New("Could not get rows affected"), err) + } - 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") + if i == 0 { + return "", nil + } else { + return token, nil } } -func sendVerificationEmail(db *sql.DB, userId string, email string) { + +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) @@ -595,7 +383,7 @@ func sendVerificationEmail(db *sql.DB, userId string, email string) { } if token == "" { - token, err := utils.RandomToken() + token, err := randomToken() if err != nil { utils.LogError("Could not generate token", err) return @@ -617,8 +405,31 @@ func sendVerificationEmail(db *sql.DB, userId string, email string) { utils.SendMail(email, "Welcome to ME-FIT", w.String()) } +func createSession(db *sql.DB, user_uuid uuid.UUID) (*types.SessionId, error) { + sessionId, err := randomToken() + if err != nil { + return nil, err + } + + // 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) + return nil, ErrInternalServer + } + + _, err = db.Exec("INSERT INTO session (session_id, user_uuid, created_at) VALUES (?, ?, datetime())", sessionId, user_uuid) + if err != nil { + utils.LogError("Could not insert new session", err) + return nil, ErrInternalServer + } + + sessionIdType := types.SessionId(sessionId) + return &sessionIdType, nil +} + func tryCreateSessionAndSetCookie(r *http.Request, w http.ResponseWriter, db *sql.DB, user_uuid uuid.UUID) bool { - sessionId, err := utils.RandomToken() + sessionId, err := randomToken() if err != nil { utils.LogError("Could not generate session ID", err) auth.Error("Internal Server Error").Render(r.Context(), w) @@ -668,3 +479,30 @@ func checkPassword(password string) error { return nil } } + +//TODO: delete + +func getSessionID(r *http.Request) types.SessionId { + for _, c := range r.Cookies() { + if c.Name == "id" { + return types.SessionId(c.Value) + } + } + return "" +} + +func GetUserFromRequest(db *sql.DB, r *http.Request) *types.User { + sessionId := getSessionID(r) + return GetUserFromSessionId(db, sessionId) +} + +func randomToken() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + utils.LogError("Could not generate random token", err) + return "", ErrInternalServer + } + + return base64.StdEncoding.EncodeToString(b), nil +} diff --git a/service/auth_test.go b/service/auth_test.go new file mode 100644 index 0000000..1665b6d --- /dev/null +++ b/service/auth_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "me-fit/types" + "me-fit/utils" + + "database/sql" + "testing" + + "github.com/google/uuid" +) + +func mustSetup(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Could not open Database data.db: %v", err) + } + utils.MustRunMigrationsTest(db, "../") + return db +} + +func TestGetUserFromSessionIfSessionNotExpired(t *testing.T) { + db := mustSetup(t) + defer db.Close() + + expected := types.NewUser(uuid.New(), "email", "session_id", true) + + db.Exec(`INSERT INTO user ( + user_uuid, email, email_verified, email_verified_at, + is_admin, password, salt, created_at) + VAlUES ( + ?, ?, 1, datetime(), + 0, "password", "salt", datetime())`, expected.Id, expected.Email) + db.Exec(`INSERT INTO session (session_id, user_uuid, created_at) VALUES (?, ?, datetime('now', '-2 hour'))`, expected.SessionId, expected.Id) + + actual := GetUserFromSessionId(db, expected.SessionId) + + if *actual != *expected { + t.Errorf("Expected %v, got %v", *expected, *actual) + } +} + +func TestGetUserFromSessionIfSessionInFuture(t *testing.T) { + db := mustSetup(t) + defer db.Close() + + expected := types.NewUser(uuid.New(), "email", "session_id", true) + + db.Exec(`INSERT INTO user ( + user_uuid, email, email_verified, email_verified_at, + is_admin, password, salt, created_at) + VAlUES ( + ?, ?, 1, datetime(), + 0, "password", "salt", datetime())`, expected.Id, expected.Email) + db.Exec(`INSERT INTO session (session_id, user_uuid, created_at) VALUES (?, ?, datetime('now', '+2 hour'))`, expected.SessionId, expected.Id) + + actual := GetUserFromSessionId(db, expected.SessionId) + + if *actual != *expected { + t.Errorf("Expected %v, got %v", *expected, *actual) + } +} + +func TestFailGetUserFromSessionIfSessionExpired(t *testing.T) { + db := mustSetup(t) + defer db.Close() + + expected := types.NewUser(uuid.New(), "email", "session_id", true) + + db.Exec(`INSERT INTO user ( + user_uuid, email, email_verified, email_verified_at, + is_admin, password, salt, created_at) + VAlUES ( + ?, ?, 1, datetime(), + 0, "password", "salt", datetime())`, expected.Id, expected.Email) + db.Exec(`INSERT INTO session (session_id, user_uuid, created_at) VALUES (?, ?, datetime('now', '-8 hour', '-1 minute'))`, expected.SessionId, expected.Id) + + actual := GetUserFromSessionId(db, expected.SessionId) + + if actual != nil { + t.Errorf("Expected nil, got %v", *actual) + } +} + +func TestGetUserFromSessionShouldFindCorrectUserBySessionId(t *testing.T) { + db := mustSetup(t) + defer db.Close() + + expected := types.NewUser(uuid.New(), "email", "session_id", true) + userId2 := uuid.New() + + db.Exec(`INSERT INTO user ( + user_uuid, email, email_verified, email_verified_at, + is_admin, password, salt, created_at) + VAlUES ( + ?, ?, 1, datetime(), + 0, "password", "salt", datetime()), + ( + ?, ?, 1, datetime(), + 0, "password", "salt", datetime()) + `, expected.Id, expected.Email, userId2, "email2") + db.Exec(` + INSERT INTO session ( + session_id, user_uuid, created_at) + VALUES + (?, ?, datetime('now')), + (?, ?, datetime('now')) + `, expected.SessionId, expected.Id, expected.SessionId+"x", userId2) + + actual := GetUserFromSessionId(db, expected.SessionId) + + if *actual != *expected { + t.Errorf("Expected %v, got %v", *expected, *actual) + } +} + +func TestValidPasswords(t *testing.T) { + passwords := []string{ + "aB!'2d2y", //normal + "v-#:j`fQurudEEUk#xA)uzI-B+'eZW3`F*5Eaf+{YID#PWuD.TbyH'f