user password support

This commit is contained in:
Gustavo Maronato 2023-08-17 19:26:33 -03:00
parent a5d27a43be
commit 13f64fdb7d
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
11 changed files with 288 additions and 6 deletions

6
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.