Files
RawMark/internal/auth/auth.go
T

289 lines
7.6 KiB
Go

// Package auth implements single-user login (Argon2id hashes, session tokens, rate limiting) and API-token validation.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"rawmark/internal/i18n"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
"rawmark/internal/store"
)
const (
passwordKey = "auth.password"
apiTokenPrefix = "rm_"
SessionPrefix = "rms_"
SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8
// The second recommended parameter set in RFC 9106 (memory-constrained environments).
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
// Block a source for lockout after maxFailures failures within the window.
maxFailures = 30
failureWindow = 15 * time.Minute
lockout = 15 * time.Minute
)
var (
ErrAlreadySetUp = i18n.New("setup_is_already_complete_222ec6")
ErrWeakPassword = i18n.New("password_must_contain_at_least_decf04", MinPasswordLen)
ErrBadCredentials = i18n.New("incorrect_password_4585c8")
ErrRateLimited = i18n.New("too_many_attempts_please_try_8bbf6d")
ErrBadScope = i18n.New("scope_must_be_read_or_2cb466")
)
type Manager struct {
store *store.Store
mu sync.Mutex
failures map[string][]time.Time
blocked map[string]time.Time // Source -> unblock time.
now func() time.Time
}
func New(s *store.Store) *Manager {
return &Manager{store: s, failures: map[string][]time.Time{}, blocked: map[string]time.Time{}, now: time.Now}
}
func (m *Manager) IsSetUp() (bool, error) {
_, ok, err := m.store.Setting(passwordKey)
return ok, err
}
func (m *Manager) Setup(password string) error {
// Reject initialized instances early to avoid repeated public password hashing; the database still makes concurrent first writes atomic.
setUp, err := m.IsSetUp()
if err != nil {
return err
}
if setUp {
return ErrAlreadySetUp
}
if len([]rune(password)) < MinPasswordLen {
return ErrWeakPassword
}
ok, err := m.store.SetSettingIfAbsent(passwordKey, hashPassword(password))
if err != nil {
return err
}
if !ok {
return ErrAlreadySetUp
}
return nil
}
// Login validates the password and creates a session, returning the plaintext token (only its hash is stored).
func (m *Manager) Login(ip, password string) (string, error) {
return m.AttemptLogin(ip, password, nil)
}
// AttemptLogin validates the login challenge and password within one rate-limit boundary; setup needs no challenge.
func (m *Manager) AttemptLogin(ip, password string, challenge func() error) (string, error) {
if !m.allow(ip) {
return "", ErrRateLimited
}
if challenge != nil {
if err := challenge(); err != nil {
m.recordFailure(ip)
return "", err
}
}
encoded, ok, err := m.store.Setting(passwordKey)
if err != nil {
return "", err
}
if !ok {
return "", ErrBadCredentials
}
valid, err := verifyPassword(encoded, password)
if err != nil {
return "", err
}
if !valid {
m.recordFailure(ip)
return "", ErrBadCredentials
}
m.clearFailures(ip)
token := SessionPrefix + randomToken()
if err := m.store.CreateSession(hashToken(token), m.now().Add(SessionTTL)); err != nil {
return "", err
}
return token, nil
}
func (m *Manager) Validate(token string) (bool, error) {
_, ok, err := m.SessionExpiry(token)
return ok, err
}
func (m *Manager) SessionExpiry(token string) (time.Time, bool, error) {
if !strings.HasPrefix(token, SessionPrefix) {
return time.Time{}, false, nil
}
return m.store.SessionExpiry(hashToken(token), m.now())
}
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
// ChangePassword verifies the current password, sets the new one, and revokes all sessions except keepToken.
func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
if !m.allow(ip) {
return ErrRateLimited
}
encoded, _, err := m.store.Setting(passwordKey)
if err != nil {
return err
}
ok, err := verifyPassword(encoded, current)
if err != nil {
return err
}
if !ok {
m.recordFailure(ip)
return ErrBadCredentials
}
if len([]rune(next)) < MinPasswordLen {
return ErrWeakPassword
}
if err := m.store.SetSetting(passwordKey, hashPassword(next)); err != nil {
return err
}
return m.store.DeleteOtherSessions(hashToken(keepToken))
}
// ---- API tokens ----
// CreateAPIToken creates a token and returns its plaintext value for one-time display.
func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) {
if scope != "read" && scope != "write" {
return "", store.APIToken{}, ErrBadScope
}
plain := apiTokenPrefix + randomToken()
t := store.APIToken{ID: randomCode(), Name: name, Hash: hashToken(plain), Scope: scope, CreatedAt: time.Now().UnixMilli()}
if err := m.store.CreateAPIToken(t); err != nil {
return "", store.APIToken{}, err
}
return plain, t, nil
}
// ValidateAPIToken returns a token's scope; ok is false for an invalid token.
func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) {
if !strings.HasPrefix(plain, apiTokenPrefix) {
return "", false, nil
}
return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli())
}
// ---- Rate limiting: temporarily block sources with too many failures within the window ----
func (m *Manager) allow(ip string) bool {
m.mu.Lock()
defer m.mu.Unlock()
until, ok := m.blocked[ip]
if ok && m.now().Before(until) {
return false
}
delete(m.blocked, ip)
return true
}
func (m *Manager) recordFailure(ip string) {
m.mu.Lock()
defer m.mu.Unlock()
now := m.now()
cutoff := now.Add(-failureWindow)
recent := m.failures[ip][:0]
for _, t := range m.failures[ip] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
recent = append(recent, now)
if len(recent) >= maxFailures {
m.blocked[ip] = now.Add(lockout)
delete(m.failures, ip)
return
}
m.failures[ip] = recent
}
func (m *Manager) clearFailures(ip string) {
m.mu.Lock()
delete(m.failures, ip)
m.mu.Unlock()
}
// ---- Passwords and tokens ----
func hashPassword(password string) string {
salt := make([]byte, 16)
rand.Read(salt)
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64 := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
b64.EncodeToString(salt), b64.EncodeToString(key))
}
func verifyPassword(encoded, password string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, i18n.New("unrecognized_password_hash_format_2f5457")
}
var mem uint32
var t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &p); err != nil {
return false, i18n.New("could_not_parse_password_hash_3cd93b", err)
}
b64 := base64.RawStdEncoding
salt, err := b64.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := b64.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, t, mem, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
func randomToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// randomCode generates an API-token identifier such as ABCD-EFGH, excluding ambiguous characters.
func randomCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8)
rand.Read(b)
out := make([]byte, 0, 9)
for i, c := range b {
if i == 4 {
out = append(out, '-')
}
out = append(out, alphabet[int(c)%len(alphabet)])
}
return string(out)
}