161 lines
4.0 KiB
Go
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
|
|
}
|