diff --git a/go.mod b/go.mod index b440573..82467ad 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,8 @@ require ( golang.org/x/sync v0.3.0 ) -require github.com/ajg/form v1.5.1 // indirect +require ( + github.com/ajg/form v1.5.1 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/go.sum b/go.sum index 4013e7e..e7f2672 100644 --- a/go.sum +++ b/go.sum @@ -8,5 +8,9 @@ github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/errs/errors.go b/internal/errs/errors.go index f38c53c..ea61ae8 100644 --- a/internal/errs/errors.go +++ b/internal/errs/errors.go @@ -20,6 +20,10 @@ var ( ErrUserExists = errors.New("user already exists") // ErrInvalidUser ErrInvalidUser = errors.New("invalid user") + // ErrFailedAuthentication + ErrFailedAuthentication = errors.New("failed authentication") + // ErrInvalidUsernameOrPassword + ErrInvalidUsernameOrPassword = errors.New("invalid username or password") ) func Error(err error, msg string) error { diff --git a/internal/server/api/handler.go b/internal/server/api/handler.go index 8288897..ce1e0bc 100644 --- a/internal/server/api/handler.go +++ b/internal/server/api/handler.go @@ -67,6 +67,7 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) { type loginForm struct { Username string `json:"username"` + Password string `json:"password"` } var login *loginForm @@ -78,13 +79,16 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) { } // Get user from storage - user, err := h.users.FindUser(ctx, login.Username) + user, err := h.users.AuthenticateUser(ctx, login.Username, login.Password) if err != nil { - if errors.Is(err, errs.ErrUserDoesNotExist) { - server.RenderRender(w, r, server.ErrUnauthorized(err)) + if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) { + // If the username or password are wrong, return invalid username/password + server.RenderRender(w, r, server.ErrUnauthorized(errs.ErrInvalidUsernameOrPassword)) } else if errors.Is(err, errs.ErrInvalidUser) { + // If the request was invalid, return bad request server.RenderRender(w, r, server.ErrBadRequest(err)) } else { + // Else, server error server.RenderRender(w, r, server.ErrServerError(err)) } @@ -114,14 +118,34 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get the user from the json body - var user *models.User + type signupForm struct { + models.User + Password string `json:"password,omitempty"` + } + var form *signupForm - if err := render.DecodeJSON(r.Body, &user); err != nil { + if err := render.DecodeJSON(r.Body, &form); err != nil { + err = fmt.Errorf("failed to parse form: %w", err) server.RenderRender(w, r, server.ErrBadRequest(err)) return } + // Get user and pass from form + user := &form.User + pass := form.Password + + // Hash the password into the user + if err := user.SetPassword(pass); err != nil { + if errors.Is(err, errs.ErrInvalidUsernameOrPassword) { + server.RenderRender(w, r, server.ErrBadRequest(err)) + } else { + server.RenderRender(w, r, server.ErrServerError(err)) + } + + return + } + // Create user err := h.users.CreateUser(ctx, user) if err != nil { diff --git a/internal/service/user/userservice.go b/internal/service/user/userservice.go index 6918bf3..07004d9 100644 --- a/internal/service/user/userservice.go +++ b/internal/service/user/userservice.go @@ -73,6 +73,25 @@ func (s *UserService) DeleteUser(ctx context.Context, user *models.User) error { return nil } +func (s *UserService) AuthenticateUser(ctx context.Context, username string, password string) (user *models.User, err error) { + // Get user from storage + user, err = s.FindUser(ctx, username) + if err != nil { + return &models.User{}, fmt.Errorf("failed to find user: %w", err) + } + + // Try to authenticate + match, err := user.Authenticate(password) + if err != nil { + return &models.User{}, fmt.Errorf("failed to authenticate user: %w", err) + } else if !match { + return &models.User{}, errs.ErrFailedAuthentication + } + + // Success + return user, nil +} + func UsernameIsValid(username string) error { if !UsernameRegex.MatchString(username) { return errs.Error(errs.ErrInvalidUser, fmt.Sprintf("username must match %s", UsernameRegex)) @@ -88,5 +107,9 @@ func UserIsValid(user *models.User) error { return err } + if user.GetPasswordHash() == "" { + return fmt.Errorf("missing password hash: %w", errs.ErrInvalidUser) + } + return nil } diff --git a/internal/storage/models/user.go b/internal/storage/models/user.go index 2d4b7f1..fd03b60 100644 --- a/internal/storage/models/user.go +++ b/internal/storage/models/user.go @@ -1,5 +1,63 @@ package models +import ( + "fmt" + + "git.maronato.dev/maronato/goshort/internal/errs" + "git.maronato.dev/maronato/goshort/internal/util/passwords" +) + type User struct { Username string `json:"username"` + password string `json:"-"` +} + +// NewAuthenticatableUser is a elper function for storages that takes +// a public user and adds a hashed password to it. +func NewAuthenticatableUser(user *User, hashedPass string) *User { + + return &User{ + Username: user.Username, + password: hashedPass, + } +} + +// Authenticates a user checking if the passwords match +func (u *User) Authenticate(pass string) (match bool, err error) { + if u.password == "" { + return false, fmt.Errorf("this user cannot be authenticated") + } + match, err = passwords.VerifyPassword(pass, u.password) + if err != nil { + return false, fmt.Errorf("authentication failed because of an error: %w", err) + } + + return match, nil +} + +const ( + minPassLength = 8 + maxPassLength = 128 +) + +// SetPassword sets a password +func (u *User) SetPassword(pass string) error { + if len(pass) < minPassLength || len(pass) > maxPassLength { + return fmt.Errorf("invalid password: %w", errs.ErrInvalidUsernameOrPassword) + } + + hash, err := passwords.HashPassword(pass) + if err != nil { + return fmt.Errorf("failed to save password: %w", err) + } + + // Set the hashed password + u.password = hash + + return nil +} + +// GetPasswordHash Returns the password hash to be saved in a storage. +func (u *User) GetPasswordHash() string { + return u.password } diff --git a/internal/util/passwords/compare.go b/internal/util/passwords/compare.go new file mode 100644 index 0000000..fbad5e5 --- /dev/null +++ b/internal/util/passwords/compare.go @@ -0,0 +1,66 @@ +package passwords + +import ( + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +func VerifyPassword(password, encodedHash string) (match bool, err error) { + // Extract the parameters, salt and derived key from the encoded password + // hash. + p, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, fmt.Errorf("failed to verify password: %w", err) + } + + // Derive the key from the other password using the same parameters. + otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) + + // Check that the contents of the hashed passwords are identical. Note + // that we are using the subtle.ConstantTimeCompare() function for this + // to help prevent timing attacks. + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + return false, nil +} + +func decodeHash(encodedHash string) (p *argonParams, salt, hash []byte, err error) { + vals := strings.Split(encodedHash, "$") + if len(vals) != 6 { + return nil, nil, nil, ErrInvalidHash + } + + var version int + _, err = fmt.Sscanf(vals[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, err + } + if version != argon2.Version { + return nil, nil, nil, ErrIncompatibleVersion + } + + p = &argonParams{} + _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism) + if err != nil { + return nil, nil, nil, err + } + + salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4]) + if err != nil { + return nil, nil, nil, err + } + p.saltLength = uint32(len(salt)) + + hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5]) + if err != nil { + return nil, nil, nil, err + } + p.keyLength = uint32(len(hash)) + + return p, salt, hash, nil +} diff --git a/internal/util/passwords/errors.go b/internal/util/passwords/errors.go new file mode 100644 index 0000000..36a94db --- /dev/null +++ b/internal/util/passwords/errors.go @@ -0,0 +1,10 @@ +package passwords + +import ( + "errors" +) + +var ( + ErrInvalidHash = errors.New("the encoded hash is not in the correct format") + ErrIncompatibleVersion = errors.New("incompatible version of argon2") +) diff --git a/internal/util/passwords/hash.go b/internal/util/passwords/hash.go new file mode 100644 index 0000000..0d4c68b --- /dev/null +++ b/internal/util/passwords/hash.go @@ -0,0 +1,48 @@ +package passwords + +import ( + "encoding/base64" + "fmt" + + "golang.org/x/crypto/argon2" +) + +func HashPassword(password string) (encodedHash string, err error) { + // Get the params + params := newArgonParams() + + // Generate the salt + salt, err := generateRandomBytes(params.saltLength) + if err != nil { + return "", fmt.Errorf("failed to generate password salt: %w", err) + } + + // Pass the plaintext password, salt and parameters to the argon2.IDKey + // function. This will generate a hash of the password using the Argon2id + // variant. + hash := argon2.IDKey( + []byte(password), + salt, + params.iterations, + params.memory, + params.parallelism, + params.keyLength, + ) + + // Base64 encode the salt and hashed password. + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + // Return a string using the standard encoded hash representation. + encodedHash = fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + params.memory, + params.iterations, + params.parallelism, + b64Salt, + b64Hash, + ) + + return encodedHash, nil +} diff --git a/internal/util/passwords/util.go b/internal/util/passwords/util.go new file mode 100644 index 0000000..b1a8a3d --- /dev/null +++ b/internal/util/passwords/util.go @@ -0,0 +1,41 @@ +package passwords + +import ( + "crypto/rand" +) + +const ( + defaultMemory uint32 = 512 * 1024 // 512 MiB + defaultIterations uint32 = 1 + defaultParallelism uint8 = 8 + defaultSaltLength uint32 = 16 + defaultKeyLength uint32 = 32 +) + +type argonParams struct { + memory uint32 + iterations uint32 + parallelism uint8 + saltLength uint32 + keyLength uint32 +} + +func newArgonParams() *argonParams { + return &argonParams{ + memory: defaultMemory, + iterations: defaultIterations, + parallelism: defaultParallelism, + saltLength: defaultSaltLength, + keyLength: defaultKeyLength, + } +} + +func generateRandomBytes(n uint32) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + + return b, nil +} diff --git a/results.bin b/results.bin deleted file mode 100644 index c1d927b..0000000 Binary files a/results.bin and /dev/null differ