diff --git a/handler.go b/handler.go index 6610234..203b205 100644 --- a/handler.go +++ b/handler.go @@ -3,6 +3,7 @@ package main import ( "me-fit/middleware" "me-fit/service" + "me-fit/template/mail" "me-fit/utils" "database/sql" @@ -14,23 +15,37 @@ func getHandler(db *sql.DB) http.Handler { router.HandleFunc("/", service.HandleIndexAnd404(db)) - router.HandleFunc("/mail", func(w http.ResponseWriter, r *http.Request) { - utils.SendWelcomeMail("timwundenberg@outlook.de") - }) - // Serve static files (CSS, JS and images) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) - router.HandleFunc("/workout", service.HandleWorkoutPage(db)) - router.HandleFunc("POST /api/workout", service.HandleWorkoutNewComp(db)) - router.HandleFunc("GET /api/workout", service.HandleWorkoutGetComp(db)) - router.HandleFunc("DELETE /api/workout/{id}", service.HandleWorkoutDeleteComp(db)) + 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))) - router.HandleFunc("/auth/signin", service.HandleSignInPage(db)) - router.HandleFunc("/auth/signup", service.HandleSignUpPage(db)) - router.HandleFunc("/api/auth/signup", service.HandleSignUpComp(db)) - router.HandleFunc("/api/auth/signin", service.HandleSignInComp(db)) - router.HandleFunc("/api/auth/signout", service.HandleSignOutComp(db)) + // 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/verify-email", service.HandleSignUpVerifyResponsePage(db)) // The link contained in the email + 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/verify-resend", service.HandleVerifyResendComp(db)) + + if utils.Environment == "dev" { + router.HandleFunc("/mail/", handleMails) + } return middleware.Logging(middleware.EnableCors(router)) } + +func handleMails(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/mail/register" { + mail.Register("test-code").Render(r.Context(), w) + } +} + +func auth(db *sql.DB, h http.Handler) http.Handler { + return middleware.EnsureValidSession(db, h) +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..e458f2b --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "me-fit/utils" + + "context" + "database/sql" + "net/http" +) + +func EnsureValidSession(db *sql.DB, next http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // handled, redirected := handleSignInAndOutRoutes(db, w, r) + // if handled { + // if !redirected { + // next.ServeHTTP(w, r) + // } + // + // return + // } + + user := utils.GetUserFromSession(db, r) + if user == nil || !user.SessionValid { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + if r.URL.Path != "/auth/verify" && !user.EmailVerified { + utils.DoRedirect(w, r, "/auth/verify") + return + } + + ctx := context.WithValue(r.Context(), utils.ContextKeyUser, user) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// func handleSignInAndOutRoutes(db *sql.DB, w http.ResponseWriter, r *http.Request) (bool, bool) { +// if r.URL.Path != "/auth/signin" && r.URL.Path != "/auth/signup" && r.URL.Path != "/api/auth/verify-resend" { +// return false, false +// } +// +// sessionId := getSessionID(r) +// user := verifySession(db, sessionId) +// if user == nil || !user.SessionValid { +// return true, false +// } +// +// if user.EmailVerified { +// utils.DoRedirect(w, r, "/") +// } else { +// utils.DoRedirect(w, r, "/auth/verify") +// } +// +// return true, true +// } diff --git a/migration/003_user_mail_verification.up.sql b/migration/003_user_mail_verification.up.sql new file mode 100644 index 0000000..99cf4a6 --- /dev/null +++ b/migration/003_user_mail_verification.up.sql @@ -0,0 +1,2 @@ + +ALTER TABLE user ADD COLUMN email_verified_at DATETIME DEFAULT NULL; diff --git a/service/auth.go b/service/auth.go index 0fdb56e..b589a42 100644 --- a/service/auth.go +++ b/service/auth.go @@ -13,37 +13,92 @@ import ( "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" ) -type User struct { - id uuid.UUID - email string -} - func HandleSignInPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user_comp := UserInfoComp(verifySessionAndReturnUser(db, r)) - signIn := auth.SignInOrUp(true) - template.Layout(signIn, user_comp).Render(r.Context(), w) + user := utils.GetUserFromSession(db, r) + if user == nil || !user.SessionValid { + userComp := UserInfoComp(nil) + signIn := auth.SignInOrUpComp(true) + template.Layout(signIn, userComp).Render(r.Context(), w) + return + } else if !user.EmailVerified { + utils.DoRedirect(w, r, "/auth/verify") + return + } else { + utils.DoRedirect(w, r, "/") + return + } } } func HandleSignUpPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user_comp := UserInfoComp(verifySessionAndReturnUser(db, r)) - signIn := auth.SignInOrUp(false) - template.Layout(signIn, user_comp).Render(r.Context(), w) + user := utils.GetUserFromSession(db, r) + if user == nil || !user.SessionValid { + userComp := UserInfoComp(nil) + signUpComp := auth.SignInOrUpComp(false) + template.Layout(signUpComp, userComp).Render(r.Context(), w) + return + } else if !user.EmailVerified { + utils.DoRedirect(w, r, "/auth/verify") + return + } else { + utils.DoRedirect(w, r, "/") + return + } } } -func UserInfoComp(user *User) templ.Component { +func HandleSignUpVerifyPage(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := utils.GetUserFromSession(db, r) + if user == nil || !user.SessionValid { + utils.DoRedirect(w, r, "/auth/signin") + return + } + if user.EmailVerified { + utils.DoRedirect(w, r, "/") + return + } else { + userComp := UserInfoComp(user) + signIn := auth.VerifyComp() + template.Layout(signIn, userComp).Render(r.Context(), w) + } + } +} + +func HandleSignUpVerifyResponsePage(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + utils.DoRedirect(w, r, "/auth/verify") + return + } + + userId, err := uuid.Parse(code) + if err != nil { + utils.DoRedirect(w, r, "/auth/verify") + return + } + + _, err = db.Exec("UPDATE user SET email_verified = true, email_verified_at = datetime() WHERE user_uuid = ?", userId) + utils.DoRedirect(w, r, "/") + } +} + +func UserInfoComp(user *types.User) templ.Component { if user != nil { - return auth.UserComp(user.email) + return auth.UserComp(user.Email) } else { return auth.UserComp("") } @@ -108,7 +163,10 @@ func HandleSignUpComp(db *sql.DB) http.HandlerFunc { return } - w.Header().Add("HX-Redirect", "/") + // Send verification email as a goroutine + go sendVerificationEmail(db, r, userId.String(), email) + + utils.DoRedirect(w, r, "/auth/verify") } } @@ -120,10 +178,13 @@ func HandleSignInComp(db *sql.DB) http.HandlerFunc { var result bool = true start := time.Now() - var userId uuid.UUID - var savedHash []byte - var salt []byte - err := db.QueryRow("SELECT user_uuid, password, salt FROM user WHERE email = ?", email).Scan(&userId, &savedHash, &salt) + 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 } @@ -153,7 +214,11 @@ func HandleSignInComp(db *sql.DB) http.HandlerFunc { time.Sleep(time.Duration(timeToWait) * time.Millisecond) if result { - w.Header().Add("HX-Redirect", "/") + if !emailVerified { + utils.DoRedirect(w, r, "/auth/verify") + } else { + utils.DoRedirect(w, r, "/") + } } else { auth.Error("Invalid email or password").Render(r.Context(), w) } @@ -162,13 +227,15 @@ func HandleSignInComp(db *sql.DB) http.HandlerFunc { func HandleSignOutComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := getSessionID(r) + user := utils.GetUserFromSession(db, r) - _, err := db.Exec("DELETE FROM session WHERE session_id = ?", id) - if err != nil { - slog.Error("Could not delete session: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + if user != nil { + _, err := db.Exec("DELETE FROM session WHERE session_id = ?", user.SessionId) + if err != nil { + slog.Error("Could not delete session: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } c := http.Cookie{ @@ -186,6 +253,29 @@ func HandleSignOutComp(db *sql.DB) http.HandlerFunc { } } +func HandleVerifyResendComp(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := utils.GetUserFromSession(db, r) + if user == nil || !user.SessionValid || user.EmailVerified { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + sendVerificationEmail(db, r, user.Id.String(), user.Email) + + w.Write([]byte("
Verification email sent
")) + } +} + +func sendVerificationEmail(db *sql.DB, r *http.Request, userId string, email string) { + registerComp := tempMail.Register(userId) + + var writer strings.Builder + + registerComp.Render(r.Context(), &writer) + utils.SendMail(email, "Welcome to ME-FIT", writer.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) @@ -223,41 +313,6 @@ func tryCreateSessionAndSetCookie(r *http.Request, w http.ResponseWriter, db *sq return true } -func getSessionID(r *http.Request) string { - for _, c := range r.Cookies() { - if c.Name == "id" { - return c.Value - } - } - return "" -} - -func verifySessionAndReturnUser(db *sql.DB, r *http.Request) *User { - sessionId := getSessionID(r) - if sessionId == "" { - return nil - } - - var user User - var createdAt time.Time - - err := db.QueryRow(` - SELECT u.user_uuid, u.email, s.created_at - FROM session s - INNER JOIN user u ON s.user_uuid = u.user_uuid - WHERE session_id = ?`, sessionId).Scan(&user.id, &user.email, &createdAt) - if err != nil { - slog.Warn("Could not verify session: " + err.Error()) - return nil - } - - if createdAt.Add(time.Duration(8 * time.Hour)).Before(time.Now()) { - return nil - } - - return &user -} - func getHashPassword(password string, salt []byte) []byte { return argon2.IDKey([]byte(password), salt, 1, 64*1024, 1, 16) } diff --git a/service/static_ui.go b/service/static_ui.go index 644c419..050bfb4 100644 --- a/service/static_ui.go +++ b/service/static_ui.go @@ -3,6 +3,7 @@ package service import ( "database/sql" "me-fit/template" + "me-fit/utils" "net/http" "github.com/a-h/templ" @@ -10,8 +11,10 @@ import ( func HandleIndexAnd404(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + user := utils.GetUserFromSession(db, r) + var comp templ.Component = nil - userComp := UserInfoComp(verifySessionAndReturnUser(db, r)) + userComp := UserInfoComp(user) if r.URL.Path != "/" { comp = template.Layout(template.NotFound(), userComp) diff --git a/service/workout.go b/service/workout.go index be74d7e..479da55 100644 --- a/service/workout.go +++ b/service/workout.go @@ -4,6 +4,7 @@ import ( "log/slog" "me-fit/template" "me-fit/template/workout" + "me-fit/utils" "database/sql" "net/http" @@ -13,9 +14,9 @@ import ( func HandleWorkoutPage(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user := verifySessionAndReturnUser(db, r) + user := utils.GetUser(r) if user == nil { - http.Redirect(w, r, "/auth/signin", http.StatusSeeOther) + utils.DoRedirect(w, r, "/auth/signin") return } @@ -28,9 +29,9 @@ func HandleWorkoutPage(db *sql.DB) http.HandlerFunc { func HandleWorkoutNewComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user := verifySessionAndReturnUser(db, r) + user := utils.GetUser(r) if user == nil { - w.Header().Add("HX-Redirect", "/auth/signin") + utils.DoRedirect(w, r, "/auth/signin") return } @@ -63,7 +64,7 @@ func HandleWorkoutNewComp(db *sql.DB) http.HandlerFunc { } var rowId int - err = db.QueryRow("INSERT INTO workout (user_id, date, type, sets, reps) VALUES (?, ?, ?, ?, ?) RETURNING rowid", user.id, date, typeStr, sets, reps).Scan(&rowId) + err = db.QueryRow("INSERT INTO workout (user_id, date, type, sets, reps) VALUES (?, ?, ?, ?, ?) RETURNING rowid", user.Id, date, typeStr, sets, reps).Scan(&rowId) if err != nil { slog.Error(err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -83,13 +84,13 @@ func HandleWorkoutNewComp(db *sql.DB) http.HandlerFunc { func HandleWorkoutGetComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user := verifySessionAndReturnUser(db, r) + user := utils.GetUser(r) if user == nil { - w.Header().Add("HX-Redirect", "/auth/signin") + utils.DoRedirect(w, r, "/auth/signin") return } - rows, err := db.Query("SELECT rowid, date, type, sets, reps FROM workout WHERE user_id = ? ORDER BY date desc", user.id) + rows, err := db.Query("SELECT rowid, date, type, sets, reps FROM workout WHERE user_id = ? ORDER BY date desc", user.Id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -120,9 +121,9 @@ func HandleWorkoutGetComp(db *sql.DB) http.HandlerFunc { func HandleWorkoutDeleteComp(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user := verifySessionAndReturnUser(db, r) + user := utils.GetUser(r) if user == nil { - w.Header().Add("HX-Redirect", "/auth/signin") + utils.DoRedirect(w, r, "/auth/signin") return } @@ -132,7 +133,7 @@ func HandleWorkoutDeleteComp(db *sql.DB) http.HandlerFunc { return } - res, err := db.Exec("DELETE FROM workout WHERE user_id = ? AND rowid = ?", user.id, rowId) + res, err := db.Exec("DELETE FROM workout WHERE user_id = ? AND rowid = ?", user.Id, rowId) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/template/auth/sign_in_or_up.templ b/template/auth/sign_in_or_up.templ index 41ba568..2f69a83 100644 --- a/template/auth/sign_in_or_up.templ +++ b/template/auth/sign_in_or_up.templ @@ -1,6 +1,6 @@ package auth -templ SignInOrUp(isSignIn bool) { +templ SignInOrUpComp(isSignIn bool) {