Files
micromdm/internal/data/user/user.go
Victor Vrantchan 695856b205 Handle unique constraint for user registration.
Returns an error if the same username or email already exists.
2021-02-20 18:24:39 -05:00

198 lines
4.7 KiB
Go

// Package user exposes methods for representing users in a database.
package user
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"time"
"golang.org/x/crypto/scrypt"
"micromdm.io/v2/pkg/id"
)
// User is a MicroMDM user.
type User struct {
ID string
Username string
Email string
Password []byte
Salt []byte
ConfirmationHash *string
CreatedAt time.Time
UpdatedAt time.Time
}
func columns() []string {
return []string{
"id",
"username",
"email",
"password",
"salt",
"confirmation_hash",
"created_at",
"updated_at",
}
}
// IsConfirmed returns true after the user confirms registration to the site.
// The user creation process always creates a confirmation token,
// which is deleted when the user accepts, by following the URL in email.
// Confirmation methods other than email should be possible to implement.
func (u *User) IsConfirmed() bool { return u.ConfirmationHash == nil }
// ValidatePassword compares the plaintext password with a stored password hash.
func (u *User) ValidatePassword(plaintext string) error {
derived, err := scrypt.Key([]byte(plaintext), u.Salt, scryptCost, 8, 1, 32)
if err != nil {
return err
}
if 1 != subtle.ConstantTimeCompare(u.Password, derived) {
return errors.New("password does not match")
}
return nil
}
func (u *User) setPassword(plaintext string) error {
salt, err := random(32, base64.StdEncoding)
if err != nil {
return err
}
derived, err := scrypt.Key([]byte(plaintext), salt, scryptCost, 8, 1, 32)
if err != nil {
return err
}
u.Salt = salt
u.Password = derived
return nil
}
func random(keySize int, enc *base64.Encoding) ([]byte, error) {
key := make([]byte, keySize)
_, err := rand.Read(key)
if err != nil {
return nil, err
}
buf := make([]byte, enc.EncodedLen(len(key)))
enc.Encode(buf, key)
return buf, nil
}
// DB
func create(username, email, password string) (*User, error) {
val := Error{invalid: make(map[string]string)}
if username == "" {
val.invalid["username"] = constraints["chk_username_not_empty"]["username"]
}
if email == "" {
val.invalid["email"] = constraints["chk_email_not_empty"]["email"]
}
if password == "" {
val.invalid["password"] = constraints["chk_password_not_empty"]["password"]
}
if len(val.invalid) > 0 {
return nil, val
}
u := &User{
ID: id.New(),
Username: username,
Email: email,
}
confirmationHash, err := random(32, base64.RawURLEncoding)
if err != nil {
return nil, fmt.Errorf("create confirmation_hash (email %s): %w", email, err)
}
cs := string(confirmationHash)
u.ConfirmationHash = &cs
if err := u.setPassword(password); err != nil {
return nil, fmt.Errorf("set password for user (email %s): %w", email, err)
}
return u, nil
}
type Error struct {
invalid map[string]string
// missingEmail gets set when the user tries to log in without a real email.
// This causes the frontend to respond with an "Invalid email or password" message.
// Might want to change this implementation later, just needed something quick.
missingEmail string
missingHash string
}
func (err Error) Invalid() map[string]string {
switch {
case err.missingHash != "":
return map[string]string{
"confirmation_hash": "Confirmation token unknown or already used.",
}
case err.missingEmail != "":
return map[string]string{
"email": "Invalid email or password.",
"password": "Invalid email or password.",
}
default:
return err.invalid
}
}
func (err Error) Error() string {
switch {
case err.missingEmail != "":
return fmt.Sprintf("user with email %q not found", err.missingEmail)
case err.missingHash != "":
return fmt.Sprintf("user confirmation hash %q", err.missingHash)
}
switch len(err.invalid) {
case 0:
return "user validation failed"
case 1:
var key, value string
for k, v := range err.invalid {
key = k
value = v
break
}
return fmt.Sprintf("user validation failed: %s - %s", key, value)
default:
var key, value string
for k, v := range err.invalid {
key = k
value = v
break
}
return fmt.Sprintf("user validation failed: %s - %s and %d other errors", key, value, len(err.invalid)-1)
}
}
var (
constraints = map[string]map[string]string{
"chk_email_not_empty": map[string]string{"email": "You must provide an email address."},
"chk_username_not_empty": map[string]string{"username": "You must provide a username."},
"chk_password_not_empty": map[string]string{"password": "You must provide a password."},
}
constraintsUnique = map[string]map[string]string{
"users.username": map[string]string{"username": "This username already exists."},
"users.email": map[string]string{"email": "This email already exists."},
}
)