Files
mcias/data/user.go
Kyle Isom c8a6830a80 Restart.
Data foundation started, with updated guidelines and
golangci-lint config.
2025-11-16 19:22:26 -08:00

161 lines
4.0 KiB
Go

package data
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"strconv"
"time"
"golang.org/x/crypto/scrypt"
)
// AccountType distinguishes human users from system/service accounts.
type AccountType string
const (
AccountHuman AccountType = "human"
AccountSystem AccountType = "system"
)
// User represents an identity in the system. It supports password and optional TOTP.
type User struct {
ID string
Username string
Type AccountType
Roles []string
// Password hashing material
pwdHash []byte
pwdSalt []byte
// Base32 encoded TOTP secret (RFC 3548). Empty means TOTP disabled.
TOTPSecret string
}
// Scrypt parameters. Chosen for interactive logins; can be tuned later.
const (
scryptN = 1 << 15 // 32768
scryptR = 8
scryptP = 1
keyLen = 32
saltLen = 16
)
// SetPassword hashes and stores the provided password using scrypt with a random salt.
func (u *User) SetPassword(password string) error {
if password == "" {
return errors.New("password cannot be empty")
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return err
}
dk, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, keyLen)
if err != nil {
return err
}
u.pwdSalt = salt
u.pwdHash = dk
return nil
}
// CheckPassword verifies a plaintext password against the stored scrypt hash.
func (u *User) CheckPassword(password string) bool {
if len(u.pwdSalt) == 0 || len(u.pwdHash) == 0 {
return false
}
dk, err := scrypt.Key([]byte(password), u.pwdSalt, scryptN, scryptR, scryptP, keyLen)
if err != nil {
return false
}
return subtle.ConstantTimeCompare(dk, u.pwdHash) == 1
}
// PasswordHash returns a portable string representation of the scrypt hash and salt.
// The format is: scrypt:N:r:p:base64(salt):base64(hash).
func (u *User) PasswordHash() string {
if len(u.pwdSalt) == 0 || len(u.pwdHash) == 0 {
return ""
}
return "scrypt:" +
strconv.Itoa(scryptN) + ":" + strconv.Itoa(scryptR) + ":" + strconv.Itoa(scryptP) + ":" +
base64.RawStdEncoding.EncodeToString(u.pwdSalt) + ":" +
base64.RawStdEncoding.EncodeToString(u.pwdHash)
}
const passwordHashParts = 5
// LoadPasswordHash parses the value produced by PasswordHash and loads it into the user.
func (u *User) LoadPasswordHash(s string) error {
if s == "" {
u.pwdSalt = nil
u.pwdHash = nil
return nil
}
const prefix = "scrypt:"
if len(s) < len(prefix) || s[:len(prefix)] != prefix {
return errors.New("unsupported password hash format")
}
parts := splitN(s[len(prefix):], ':', passwordHashParts)
if len(parts) != passwordHashParts {
return errors.New("invalid password hash")
}
// We currently ignore parsed N,r,p and use compiled constants to avoid variable-time params.
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return err
}
hash, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return err
}
if len(hash) != keyLen {
return errors.New("invalid hash length")
}
u.pwdSalt = salt
u.pwdHash = hash
return nil
}
// VerifyTOTP checks a TOTP code for the user's TOTP secret. If no secret is set, it fails.
// The window parameter defines the allowed step skew (+/- window steps).
func (u *User) VerifyTOTP(code string, at time.Time, window int) bool {
if u.TOTPSecret == "" || code == "" {
return false
}
return defaultTOTPValidator.Validate(u.TOTPSecret, code, at, window)
}
// TOTPValidator abstracts TOTP verification to allow swapping implementations.
type TOTPValidator interface {
Validate(secret, code string, at time.Time, window int) bool
}
var defaultTOTPValidator TOTPValidator = builtinTOTPValidator{}
// splitN is like strings.SplitN but without importing another package here.
func splitN(s string, sep rune, n int) []string {
if n <= 0 {
return nil
}
out := make([]string, 0, n)
start := 0
count := 1
for i, r := range s {
if r == sep {
out = append(out, s[start:i])
start = i + 1
count++
if count == n {
break
}
}
}
if start <= len(s) {
out = append(out, s[start:])
}
return out
}