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
|
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/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 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
|
||||||
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
|
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 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
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")
|
ErrUserExists = errors.New("user already exists")
|
||||||
// ErrInvalidUser
|
// ErrInvalidUser
|
||||||
ErrInvalidUser = errors.New("invalid user")
|
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 {
|
func Error(err error, msg string) error {
|
||||||
|
|
|
@ -67,6 +67,7 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
type loginForm struct {
|
type loginForm struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var login *loginForm
|
var login *loginForm
|
||||||
|
@ -78,13 +79,16 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from storage
|
// 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 err != nil {
|
||||||
if errors.Is(err, errs.ErrUserDoesNotExist) {
|
if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
|
||||||
server.RenderRender(w, r, server.ErrUnauthorized(err))
|
// 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) {
|
} else if errors.Is(err, errs.ErrInvalidUser) {
|
||||||
|
// If the request was invalid, return bad request
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderRender(w, r, server.ErrBadRequest(err))
|
||||||
} else {
|
} else {
|
||||||
|
// Else, server error
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderRender(w, r, server.ErrServerError(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,14 +118,34 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// Get the user from the json body
|
// 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))
|
server.RenderRender(w, r, server.ErrBadRequest(err))
|
||||||
|
|
||||||
return
|
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
|
// Create user
|
||||||
err := h.users.CreateUser(ctx, user)
|
err := h.users.CreateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -73,6 +73,25 @@ func (s *UserService) DeleteUser(ctx context.Context, user *models.User) error {
|
||||||
return nil
|
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 {
|
func UsernameIsValid(username string) error {
|
||||||
if !UsernameRegex.MatchString(username) {
|
if !UsernameRegex.MatchString(username) {
|
||||||
return errs.Error(errs.ErrInvalidUser, fmt.Sprintf("username must match %s", UsernameRegex))
|
return errs.Error(errs.ErrInvalidUser, fmt.Sprintf("username must match %s", UsernameRegex))
|
||||||
|
@ -88,5 +107,9 @@ func UserIsValid(user *models.User) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.GetPasswordHash() == "" {
|
||||||
|
return fmt.Errorf("missing password hash: %w", errs.ErrInvalidUser)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,63 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||||
|
"git.maronato.dev/maronato/goshort/internal/util/passwords"
|
||||||
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Username string `json:"username"`
|
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