User signup validation

main
Drew Bednar 2 months ago
parent 687d2940c2
commit d59ae57c76

@ -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)

@ -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")
)

@ -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
}

@ -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"`
}

@ -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")
}

@ -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 {

@ -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 isnt 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")
})
}

@ -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
}

@ -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

@ -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)
}

@ -0,0 +1 @@
DROP TABLE IF EXISTS users;

@ -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;

@ -0,0 +1,30 @@
{{ define "title"}}Signup{{end}}
{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
<div>
<label>Name:</label>
{{with .Form.FieldErrors.name}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='name' value='{{.Form.Name}}'>
</div>
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Signup'>
</div>
</form>
{{end}}

@ -1,6 +1,15 @@
{{define "nav" -}}
<nav>
<a href='/'>Home</a>
<a href='/snippet/create'>Create snippet</a>
<div>
<a href='/'>Home</a>
<a href='/snippet/create'>Create snippet</a>
</div>
<div>
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
<form action='/user/logout' method='POST'>
<button>Logout</button>
</form>
</div>
</nav>
{{- end}}
Loading…
Cancel
Save