Multi-user from the first migration, as planned. - auth: argon2id password hashing (PHC encoded, parameters travel with the hash), opaque session tokens stored only as SHA-256, login rate limiting, and account enumeration resistance (unknown user hashes anyway) - sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on password change - grants: library_grants is the single access-control chokepoint. Admins see every library; everyone else sees exactly their can_read grants - bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an empty database. No default password is ever invented. - httpapi: mounts modules on a shared mux, session middleware innermost - httpx: shared JSON helpers, strict body decoding Gate: two users see different libraries. Tests cover that, plus session expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
106 lines
3.5 KiB
Go
106 lines
3.5 KiB
Go
// Package auth implements identity: password hashing, sessions, per-library
|
|
// grants, and the middleware that turns a cookie into a current user.
|
|
//
|
|
// Nothing outside this package may inspect a password or a raw session token.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// Argon2id parameters. These are the OWASP-recommended baseline for
|
|
// interactive logins (memory cost is the dominant factor). They are encoded
|
|
// into every hash, so changing them here does not invalidate old passwords —
|
|
// existing hashes keep verifying with their own stored parameters.
|
|
const (
|
|
argonTime = 2
|
|
argonMemory = 64 * 1024 // KiB, i.e. 64 MiB
|
|
argonThreads = 4
|
|
argonKeyLen = 32
|
|
argonSaltLen = 16
|
|
)
|
|
|
|
var (
|
|
// ErrInvalidHash means a stored password hash is not in the PHC format
|
|
// this package writes. It indicates database corruption, not a bad login.
|
|
ErrInvalidHash = errors.New("auth: password hash is not in PHC format")
|
|
// ErrIncompatibleVersion means the hash was made by a newer argon2 version.
|
|
ErrIncompatibleVersion = errors.New("auth: incompatible argon2 version")
|
|
)
|
|
|
|
// HashPassword derives an argon2id hash and returns it as a PHC string:
|
|
//
|
|
// $argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>
|
|
//
|
|
// The parameters travel with the hash, so verification never needs them passed
|
|
// in and parameter changes are backward compatible.
|
|
func HashPassword(password string) (string, error) {
|
|
salt := make([]byte, argonSaltLen)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", fmt.Errorf("auth: read salt: %w", err)
|
|
}
|
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, argonMemory, argonTime, argonThreads,
|
|
base64.RawStdEncoding.EncodeToString(salt),
|
|
base64.RawStdEncoding.EncodeToString(key),
|
|
), nil
|
|
}
|
|
|
|
// VerifyPassword reports whether password matches the PHC-encoded hash.
|
|
// It always performs the full derivation, so a malformed hash and a wrong
|
|
// password cost the same — there is no timing oracle for "user exists".
|
|
func VerifyPassword(password, encoded string) (bool, error) {
|
|
salt, want, params, err := decodeHash(encoded)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.threads, uint32(len(want)))
|
|
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
|
}
|
|
|
|
type argonParams struct {
|
|
time uint32
|
|
memory uint32
|
|
threads uint8
|
|
}
|
|
|
|
func decodeHash(encoded string) (salt, hash []byte, p argonParams, err error) {
|
|
parts := strings.Split(encoded, "$")
|
|
// Leading empty field from the leading '$' gives 6 fields.
|
|
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
|
|
return nil, nil, p, ErrInvalidHash
|
|
}
|
|
|
|
var version int
|
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
|
return nil, nil, p, ErrInvalidHash
|
|
}
|
|
if version != argon2.Version {
|
|
return nil, nil, p, ErrIncompatibleVersion
|
|
}
|
|
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.threads); err != nil {
|
|
return nil, nil, p, ErrInvalidHash
|
|
}
|
|
|
|
salt, err = base64.RawStdEncoding.Strict().DecodeString(parts[4])
|
|
if err != nil {
|
|
return nil, nil, p, ErrInvalidHash
|
|
}
|
|
hash, err = base64.RawStdEncoding.Strict().DecodeString(parts[5])
|
|
if err != nil {
|
|
return nil, nil, p, ErrInvalidHash
|
|
}
|
|
if len(hash) == 0 || len(salt) == 0 {
|
|
return nil, nil, p, ErrInvalidHash
|
|
}
|
|
return salt, hash, p, nil
|
|
}
|