user password support
This commit is contained in:
parent
a5d27a43be
commit
13f64fdb7d
6
go.mod
6
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
|
||||
)
|
||||
|
|
4
go.sum
4
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=
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
BIN
results.bin
BIN
results.bin
Binary file not shown.
Loading…
Reference in New Issue
Block a user