diff --git a/go.mod b/go.mod index b0c0397..b54d8ad 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.25.1 require ( github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/lib/pq v1.10.9 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/time v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 0ecf137..b21d36b 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,7 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/internal/data/users.go b/internal/data/users.go new file mode 100644 index 0000000..84c3fa1 --- /dev/null +++ b/internal/data/users.go @@ -0,0 +1,83 @@ +package data + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "greenlight.craftr.fr/internal/validator" +) + +// User struct represent an individual user. Importantly, notice how we are using the json:"-" struct tag to prevent the Password and Version fields appearing in any output when we encode it to JSON. Also notice that the Password field uses the custom password type defined below. +type User struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Email string `json:"email"` + Password password `json:"-"` + Activated bool `json:"activated"` + Version int `json:"-"` +} + +// Create a custom password type which is a struct containing the plaintext and hashed versions of the password for a user. The plaintext field is a *pointer* to a string, so that we're able to distinguish between a plaintext password not being present in the struct at all, versus a plaintext password which is the empty string "". +type password struct { + plaintext *string + hash []byte +} + +// Set : calculates the bcrypt hash of a plaintext password, and stores both the hash and the plaintext versions in the struct +func (p *password) Set(plaintextPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return err + } + + p.plaintext = &plaintextPassword + p.hash = hash + + return nil +} + +// Matches : checks weather the provided plaintext password matches the hashed password stored in the struct, returning true if it matches and false otherwise. +func (p *password) Matches(plaintextPassword string) (bool, error) { + err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) + if err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return false, nil + default: + return false, err + } + } + + return true, nil +} + +func ValidateEmail(v *validator.Validator, email string) { + v.Check(email != "", "email", "must be provided") + v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address") +} + +func ValidatePasswordPlaintext(v *validator.Validator, password string) { + v.Check(password != "", "password", "must be provided") + v.Check(len(password) >= 8, "password", "must be at least 8 bytes long") + v.Check(len(password) <= 72, "password", "must be less than 72 bytes long") +} + +func ValidateUser(v *validator.Validator, user *User) { + v.Check(user.Name != "", "name", "must be provided") + v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") + + // Call the standalone ValidateEmail() helper + ValidateEmail(v, user.Email) + + // If the plaintext password is not nil, call the standalone ValidatePasswordPlaintext() helper. + if user.Password.plaintext != nil { + ValidatePasswordPlaintext(v, *user.Password.plaintext) + } + + // If the password hash is ever nil, this will be due to a logic error in our codebase (probably because we forgot to set a password for the user). It's a useful sanity check to include here, but it's not a problem with the data provided by the client. So rather than adding an error to the validation map, we raise a panic instead. + if user.Password.hash == nil { + panic("missing password hash for user") + } +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 5d35139..3ef4d98 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -12,7 +12,7 @@ Regex pour l'e-mail à tester : 2. `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` */ var ( - EmailTX, _ = regexp.Compile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + EmailRX, _ = regexp.Compile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) ) // Validator : Contains a map of validation errors diff --git a/migrations/000004_create_users_table.down.sql b/migrations/000004_create_users_table.down.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/migrations/000004_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/000004_create_users_table.up.sql b/migrations/000004_create_users_table.up.sql new file mode 100644 index 0000000..4e8ac5e --- /dev/null +++ b/migrations/000004_create_users_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS users ( + id bigserial PRIMARY KEY, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + name text NOT NULL, + email citext UNIQUE NOT NULL, + password_hash bytea NOT NULL, + activated bool NOT NULL, + version integer NOT NULL DEFAULT 1 +); \ No newline at end of file