From a50cb265cba45f446828cc7cf439493aebe31487 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sat, 8 Feb 2025 20:53:13 -0500 Subject: [PATCH] User login --- cmd/ratchetd/main.go | 4 +- internal/model/user.go | 25 +++++++++++- internal/server/form.go | 6 +++ internal/server/handlers.go | 69 +++++++++++++++++++++++++++++++-- internal/server/routes.go | 4 +- internal/validator/validator.go | 14 ++++++- ui/html/pages/login.go.tmpl | 28 +++++++++++++ 7 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 ui/html/pages/login.go.tmpl diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index cfcf2db..7f4b82a 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -12,10 +12,9 @@ import ( rdb "git.runcible.io/learning/ratchet/internal/database" "git.runcible.io/learning/ratchet/internal/logging" "git.runcible.io/learning/ratchet/internal/server" + "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" _ "github.com/mattn/go-sqlite3" - // "git.runcible.io/learning/ratchet" - // ratchethttp "git.runcible.io/learning/ratchet/internal" ) // var ( @@ -56,6 +55,7 @@ func main() { // SessionManager sm := scs.New() + sm.Store = sqlite3store.New(db) // ratchet.Version = strings.TrimPrefix(version, "") // ratchet.Commit = commit app := server.NewRatchetApp(logger, tc, db, sm) diff --git a/internal/model/user.go b/internal/model/user.go index 1d86f19..5d97159 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -2,6 +2,7 @@ package model import ( "database/sql" + "errors" "fmt" "log/slog" "time" @@ -57,7 +58,29 @@ func (u *UserService) Insert(name, email, password string) (int, error) { } func (u *UserService) Authenticate(email, password string) (int, error) { - return 0, nil + var id int + var hashedPassword []byte + stmt := `SELECT id, hashed_password FROM users WHERE email == ?` + + err := u.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrInvalidCredentials + } else { + return 0, err + } + } + + err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return 0, ErrInvalidCredentials + } else { + return 0, err + } + } + + return id, nil } func (u *UserService) Exists(id int) (bool, error) { diff --git a/internal/server/form.go b/internal/server/form.go index c3e79e5..4fd3a31 100644 --- a/internal/server/form.go +++ b/internal/server/form.go @@ -39,6 +39,12 @@ type userSignupForm struct { validator.Validator `form:"-"` } +type userLoginForm struct { + Email string `form:"email"` + Password string `form:"password"` + validator.Validator `form:"-"` +} + func decodePostForm(r *http.Request, fd *form.Decoder, dst any) error { err := r.ParseForm() if err != nil { diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 7287aef..0c72f83 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -346,15 +346,76 @@ func handleUserSignupPost(logger *slog.Logger, tc *TemplateCache, fd *form.Decod }) } -func handleUserLoginGet() http.Handler { +func handleUserLoginGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Displaying loging form for user") + data := newTemplateData(r, sm) + data.Form = userLoginForm{} + renderTemplate(w, r, tc, http.StatusOK, "login.go.tmpl", data) + }) } -func handleUserLoginPost() http.Handler { +func handleUserLoginPost(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, fd *form.Decoder, userService *model.UserService) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Authenticate and login user") + // parse form + err := r.ParseForm() + if err != nil { + logger.Error("Failed to parse login form") + clientError(w, http.StatusBadRequest) + return + } + + form := userLoginForm{} + + err = decodePostForm(r, fd, &form) + if err != nil { + logger.Error("Failed to decode login form") + clientError(w, http.StatusBadRequest) + return + } + + form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank") + form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email") + form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank") + + if !form.Valid() { + logger.Info("An invalid form was submitted") + data := newTemplateData(r, sm) + data.Form = form + renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "login.go.tmpl", data) + return + } + + id, err := userService.Authenticate(form.Email, form.Password) + if err != nil { + if errors.Is(err, model.ErrInvalidCredentials) { + form.AddNonFieldError("Email or password is incorrect") + + data := newTemplateData(r, sm) + data.Form = form + renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "login.go.tmpl", data) + } else { + serverError(w, r, err) + } + return + } + + // Use the RenewToken() method on the current session to change the session ID. + // It's good practice to generate a new session ID when the authentication state + // or privilege levels change for the user (e.g. login and logout operations) + // This changes the ID of the current user’s session but retain any data + // associated with the session + // see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md#renew-the-session-id-after-any-privilege-level-change + err = sm.RenewToken(r.Context()) + if err != nil { + serverError(w, r, err) + return + } + + // Add the ID of the current user to the session, so that they are now "logged in" + sm.Put(r.Context(), "authenticatedUserID", id) + + http.Redirect(w, r, "/snippet/create", http.StatusSeeOther) }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index e9230ef..00c1c7b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -33,8 +33,8 @@ func addRoutes(mux *http.ServeMux, mux.Handle("GET /user/signup", sm.LoadAndSave(handleUserSignupGet(tc, sm))) mux.Handle("POST /user/signup", sm.LoadAndSave(handleUserSignupPost(logger, tc, fd, sm, userService))) - mux.Handle("GET /user/login", sm.LoadAndSave(handleUserLoginGet())) - mux.Handle("POST /user/login", sm.LoadAndSave(handleUserLoginPost())) + mux.Handle("GET /user/login", sm.LoadAndSave(handleUserLoginGet(tc, sm))) + mux.Handle("POST /user/login", sm.LoadAndSave(handleUserLoginPost(logger, tc, sm, fd, userService))) mux.Handle("POST /user/logout", sm.LoadAndSave(handleUserLogoutPost())) return mux diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 5f92f7c..5b577ef 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -1,5 +1,10 @@ // See also https://www.alexedwards.net/blog/validation-snippets-for-go // for more validation snippets +// +// A NonFieldError for example would be "Your email or password is incorrect". +// more secure because it does not leak which field was in error. Used in the login +// form + package validator import ( @@ -10,12 +15,17 @@ import ( ) type Validator struct { - FieldErrors map[string]string + NonFieldErrors []string + FieldErrors map[string]string } // Valid() returns true if the FieldErrors map doesn't contain any entries. func (v *Validator) Valid() bool { - return len(v.FieldErrors) == 0 + return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0 +} + +func (v *Validator) AddNonFieldError(message string) { + v.NonFieldErrors = append(v.NonFieldErrors, message) } // AddFieldError() adds an error message to the FieldErrors map (so long as no diff --git a/ui/html/pages/login.go.tmpl b/ui/html/pages/login.go.tmpl new file mode 100644 index 0000000..08f3ff8 --- /dev/null +++ b/ui/html/pages/login.go.tmpl @@ -0,0 +1,28 @@ +{{define "title"}}Login{{end}} + +{{define "main"}} +
+ + {{range .Form.NonFieldErrors}} +
{{.}}
+ {{end}} +
+ + {{with .Form.FieldErrors.email}} + + {{end}} + +
+
+ + {{with .Form.FieldErrors.password}} + + {{end}} + +
+
+ +
+
+{{end}} \ No newline at end of file