From d59ae57c7690d51cde53fbee1fa12a31f5c36f36 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Thu, 6 Feb 2025 17:19:42 -0500 Subject: [PATCH] User signup validation --- README.md | 13 ++++ internal/model/errors.go | 6 +- internal/model/user.go | 74 ++++-------------- internal/model/user_wtf.go | 75 +++++++++++++++++++ .../model/{user_test.go => user_wtf_test.go} | 4 +- internal/server/form.go | 7 ++ internal/server/handlers.go | 63 ++++++++++++++++ internal/server/routes.go | 7 ++ internal/server/server.go | 3 +- internal/validator/validator.go | 22 ++++++ migrations/30_users.down.sql | 1 + migrations/30_users.up.sql | 16 ++++ ui/html/pages/signup.go.tmpl | 30 ++++++++ ui/html/partials/nav.go.tmpl | 13 +++- 14 files changed, 269 insertions(+), 65 deletions(-) create mode 100644 internal/model/user_wtf.go rename internal/model/{user_test.go => user_wtf_test.go} (90%) create mode 100644 migrations/30_users.down.sql create mode 100644 migrations/30_users.up.sql create mode 100644 ui/html/pages/signup.go.tmpl diff --git a/README.md b/README.md index 64ef9ad..148cada 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ Loosely inspired by the organization of [WTFDial](https://github.com/benbjohnson This is project is an Indie reader inspired by [Feedi](https://github.com/facundoolano/feedi). +### Packages/Tools to investigate + +- ent +- chi router +- https://github.com/FiloSottile/mkcert + ### Implementation Subpackages @@ -175,6 +181,13 @@ The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can - Early returns before `next.ServeHTTP()` will hand controlflow back upsteam. - Auth middle ware is a good example. `w.WriteHeader(http.StatusForbidden); return` +Needs: +- [x] Access Logs +- [ ] Rate Limiting +- [ ] CORS +- [X] Common Headers +- [X] Recovery from Panic + ### Headers - [Primer on Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) diff --git a/internal/model/errors.go b/internal/model/errors.go index 6d3a5d2..99af866 100644 --- a/internal/model/errors.go +++ b/internal/model/errors.go @@ -3,4 +3,8 @@ package model import "errors" // TODO migrate this to an apperror -var ErrNoRecord = errors.New("models: no record found") +var ( + ErrNoRecord = errors.New("models: no record found") + ErrInvalidCredentials = errors.New("models: invalid credentials") + ErrDuplicateEmail = errors.New("models: duplicate email") +) diff --git a/internal/model/user.go b/internal/model/user.go index ae3b28f..ba5527b 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -1,75 +1,31 @@ package model import ( - "context" + "database/sql" "time" - - "git.runcible.io/learning/ratchet/internal/apperror" ) type User struct { - ID int `json:"id"` - - // User prefered name and email - Name string `json:"name"` - Email string `json:"email"` - - // Randomly generated API key for use with the API - // "-" omits the key from serialization - APIKey string `json:"-"` - - // Timestamps for user creatation and last update - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - - // List of associated Oauth authentication Objects - // Not yet implemented - // Auths []*Auth `json:"auths"` + ID int + Name string + Email string + HashedPassword []byte + CreatedAt time.Time + UpdatedAt time.Time } -func (u *User) Validate() error { - if u.Name == "" { - return apperror.Errorf(apperror.EINVALID, "User name required.") - } - return nil +type UserService struct { + DB *sql.DB } -// UserService represents a service for managing users. -type UserService interface { - // Retrieves a user by ID along with their associated auth objects - // Returns ENOTFOUND if user does not exist. - FindUserByID(ctx context.Context, id int) (*User, error) - - // Retrieves a list of users by filter. Also returns total count of matching users - // which may differ from retruned results if filter.Limit is specified. - FindUsers(ctc context.Context, filter UserFilter) ([]*User, int, error) - - // Creates a new use. This is only used for testing since users are typically - // cretaed during the OAuth creation process in the AuthService.CreateAuth(). - CreateUser(ctx context.Context, user *User) error - - // Updates a user object. Returns EUNAUTHORIZED if current user is not - // the user that is being updated. Returns ENOTFOUND if the user does not exist. - UpdateUser(ctx context.Context, id int, upd UserUpdate) (*User, error) - - // Permanently deletes a user and all owned application resources. Returns EUNAUTHORIZED - // if current user is not the user being deleted. Returns ENOTFOUND if the user - // does not exist. - DeleteUser(ctx context.Context, id int) error +func (u *UserService) Insert(name, email, password string) (int, error) { + return 0, nil } -// UserFilter respresents a filter passed to FindUsers(). -type UserFilter struct { - ID *int `json:"id"` - Email *string `json:"email"` - APIKey *string `json:"apiKey"` - - // Restrict to subset of results - Offset int `json:"offset"` - Limit int `json:"limit"` +func (u *UserService) Authenticate(email, password string) (int, error) { + return 0, nil } -type UserUpdate struct { - Name *string `json:"name"` - Email *string `json:"email"` +func (u *UserService) Exists(id int) (bool, error) { + return false, nil } diff --git a/internal/model/user_wtf.go b/internal/model/user_wtf.go new file mode 100644 index 0000000..a2e1f1d --- /dev/null +++ b/internal/model/user_wtf.go @@ -0,0 +1,75 @@ +package model + +import ( + "context" + "time" + + "git.runcible.io/learning/ratchet/internal/apperror" +) + +type Userwtf struct { + ID int `json:"id"` + + // User prefered name and email + Name string `json:"name"` + Email string `json:"email"` + + // Randomly generated API key for use with the API + // "-" omits the key from serialization + APIKey string `json:"-"` + + // Timestamps for user creatation and last update + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + // List of associated Oauth authentication Objects + // Not yet implemented + // Auths []*Auth `json:"auths"` +} + +func (u *Userwtf) Validate() error { + if u.Name == "" { + return apperror.Errorf(apperror.EINVALID, "User name required.") + } + return nil +} + +// UserService represents a service for managing users. +type UserServicewtf interface { + // Retrieves a user by ID along with their associated auth objects + // Returns ENOTFOUND if user does not exist. + FindUserByID(ctx context.Context, id int) (*Userwtf, error) + + // Retrieves a list of users by filter. Also returns total count of matching users + // which may differ from retruned results if filter.Limit is specified. + FindUsers(ctc context.Context, filter UserFilter) ([]*Userwtf, int, error) + + // Creates a new use. This is only used for testing since users are typically + // cretaed during the OAuth creation process in the AuthService.CreateAuth(). + CreateUser(ctx context.Context, user *Userwtf) error + + // Updates a user object. Returns EUNAUTHORIZED if current user is not + // the user that is being updated. Returns ENOTFOUND if the user does not exist. + UpdateUser(ctx context.Context, id int, upd UserUpdate) (*Userwtf, error) + + // Permanently deletes a user and all owned application resources. Returns EUNAUTHORIZED + // if current user is not the user being deleted. Returns ENOTFOUND if the user + // does not exist. + DeleteUser(ctx context.Context, id int) error +} + +// UserFilter respresents a filter passed to FindUsers(). +type UserFilter struct { + ID *int `json:"id"` + Email *string `json:"email"` + APIKey *string `json:"apiKey"` + + // Restrict to subset of results + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +type UserUpdate struct { + Name *string `json:"name"` + Email *string `json:"email"` +} diff --git a/internal/model/user_test.go b/internal/model/user_wtf_test.go similarity index 90% rename from internal/model/user_test.go rename to internal/model/user_wtf_test.go index 05559a4..d3d958c 100644 --- a/internal/model/user_test.go +++ b/internal/model/user_wtf_test.go @@ -8,14 +8,14 @@ import ( func TestUserValidation(t *testing.T) { t.Run("user should return invalid", func(t *testing.T) { - u := &User{} + u := &Userwtf{} if apperror.ErrorCode(u.Validate()) != apperror.EINVALID { t.Errorf("User validation should have failed but passed instead.") } }) t.Run("user validation should pass", func(t *testing.T) { - u := &User{Name: "Drew"} + u := &Userwtf{Name: "Drew"} if u.Validate() != nil { t.Errorf("User validation failed") } diff --git a/internal/server/form.go b/internal/server/form.go index 056f18c..c3e79e5 100644 --- a/internal/server/form.go +++ b/internal/server/form.go @@ -32,6 +32,13 @@ type snippetCreateForm struct { validator.Validator `form:"-"` } +type userSignupForm struct { + Name string `form:"name"` + 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 098f365..fc8765b 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -282,3 +282,66 @@ func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }) } + +func handleUserSignupGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := newTemplateData(r, sm) + data.Form = userSignupForm{} + renderTemplate(w, r, tc, http.StatusOK, "signup.go.tmpl", data) + }) +} + +func handleUserSignupPost(logger *slog.Logger, tc *TemplateCache, fd *form.Decoder, sm *scs.SessionManager) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Check that the provided name, email address and password are not blank. + // Sanity check the format of the email address. + // Ensure that the password is at least 8 characters long. + // Make sure that the email address isn’t already in use. + err := r.ParseForm() + if err != nil { + logger.Error("Failed to parse signup form") + clientError(w, http.StatusBadRequest) + return + } + form := userSignupForm{} + + err = decodePostForm(r, fd, &form) + if err != nil { + logger.Error("Failed to decode signup form") + clientError(w, http.StatusBadRequest) + return + } + + form.CheckField(validator.NotBlank(form.Name), "name", "this field cannot be blank") + 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 address") + form.CheckField(validator.NotBlank(form.Password), "password", "this field cannot be blank") + form.CheckField(validator.MinChars(form.Password, 8), "password", "this field must be at least 8 characters long") + + if !form.Valid() { + data := newTemplateData(r, sm) + data.Form = form + renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "signup.go.tmpl", data) + } + fmt.Fprintln(w, "Creating new user") + }) +} + +func handleUserLoginGet() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Displaying loging form for user") + }) +} + +func handleUserLoginPost() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Authenticate and login user") + }) +} + +func handleUserLogoutPost() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Logout the user") + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index b006bb5..ab570b0 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -29,5 +29,12 @@ func addRoutes(mux *http.ServeMux, // mux.Handle("/something", handleSomething(logger, config)) // mux.Handle("/healthz", handleHealthzPlease(logger)) // mux.Handle("/", http.NotFoundHandler()) + + mux.Handle("GET /user/signup", sm.LoadAndSave(handleUserSignupGet(tc, sm))) + mux.Handle("POST /user/signup", sm.LoadAndSave(handleUserSignupPost(logger, tc, fd, sm))) + mux.Handle("GET /user/login", sm.LoadAndSave(handleUserLoginGet())) + mux.Handle("POST /user/login", sm.LoadAndSave(handleUserLoginPost())) + mux.Handle("POST /user/logout", sm.LoadAndSave(handleUserLogoutPost())) + return mux } diff --git a/internal/server/server.go b/internal/server/server.go index a7828d9..4b1ba1a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,7 +17,7 @@ type RatchetApp struct { templateCache *TemplateCache //Services used by HTTP routes snippetService *model.SnippetService - UserService model.UserService + userService *model.UserService formDecoder *form.Decoder sessionManager *scs.SessionManager } @@ -26,6 +26,7 @@ func NewRatchetApp(logger *slog.Logger, tc *TemplateCache, db *sql.DB, sm *scs.S rs := new(RatchetApp) rs.logger = logger rs.snippetService = &model.SnippetService{DB: db} + rs.userService = &model.UserService{DB: db} rs.formDecoder = form.NewDecoder() rs.templateCache = tc rs.sessionManager = sm diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 7150f37..5f92f7c 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -3,6 +3,7 @@ package validator import ( + "regexp" "slices" "strings" "unicode/utf8" @@ -42,6 +43,11 @@ func NotBlank(value string) bool { return strings.TrimSpace(value) != "" } +// MinChars() returns true if the value contains equal to or greater than n characters +func MinChars(value string, n int) bool { + return utf8.RuneCountInString(value) >= n +} + // MaxChars() returns true if a value contains no more than n characters. func MaxChars(value string, n int) bool { return utf8.RuneCountInString(value) <= n @@ -52,3 +58,19 @@ func MaxChars(value string, n int) bool { func PermittedValue[T comparable](value T, permittedValues ...T) bool { return slices.Contains(permittedValues, value) } + +// Use the regexp.MustCompile() function to parse a regular expression pattern +// for sanity checking the format of an email address. This returns a pointer to +// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing +// this pattern once at startup and storing the compiled *regexp.Regexp in a +// variable is more performant than re-parsing the pattern each time we need it. +// This pattern is recommended by the W3C and Web Hypertext Application Technology Working Group for validating email addresses +// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address +// Because the EmailRX regexp pattern is written as an interpreted string literal, we need to double-escape special characters in the regexp with \\ for it to work correctly +var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + +// Matches() returns true if a value matches a provided compiled regular +// expression pattern. +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} diff --git a/migrations/30_users.down.sql b/migrations/30_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/30_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/30_users.up.sql b/migrations/30_users.up.sql new file mode 100644 index 0000000..3e636cd --- /dev/null +++ b/migrations/30_users.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + hashed_password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Add a trigger to keep timestamp updated. +CREATE TRIGGER users_update_timestamp +AFTER UPDATE ON users +FOR EACH ROW +BEGIN + UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; +END; diff --git a/ui/html/pages/signup.go.tmpl b/ui/html/pages/signup.go.tmpl new file mode 100644 index 0000000..13f0527 --- /dev/null +++ b/ui/html/pages/signup.go.tmpl @@ -0,0 +1,30 @@ +{{ define "title"}}Signup{{end}} + +{{define "main"}} +
+
+ + {{with .Form.FieldErrors.name}} + + {{end}} + +
+
+ + {{with .Form.FieldErrors.email}} + + {{end}} + +
+
+ + {{with .Form.FieldErrors.password}} + + {{end}} + +
+
+ +
+
+{{end}} \ No newline at end of file diff --git a/ui/html/partials/nav.go.tmpl b/ui/html/partials/nav.go.tmpl index 5f9b5ab..4c94406 100644 --- a/ui/html/partials/nav.go.tmpl +++ b/ui/html/partials/nav.go.tmpl @@ -1,6 +1,15 @@ {{define "nav" -}} {{- end}} \ No newline at end of file