Files
2026-08-25 06:37:14 +01:00

253 lines
7.8 KiB
Go

// Package authsvc wires internal/auth's stdlib crypto primitives into the
// app's single-user login: default admin/admin credentials, forced
// first-login password change, optional TOTP MFA, and stateless signed
// session tokens keyed by the same app_secret used for exchange credentials.
package authsvc
import (
"errors"
"fmt"
"regexp"
"time"
"cryptomon/internal/auth"
"cryptomon/internal/config"
"cryptomon/internal/crypto"
"cryptomon/internal/store"
)
const (
DefaultUsername = "admin"
DefaultPassword = "admin"
issuer = "Basis"
// SessionTTL is how long an issued session cookie stays valid.
SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8
)
var usernameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
var (
ErrInvalidUsername = fmt.Errorf("username must be 3-32 characters: letters, numbers, dot, underscore, or hyphen")
ErrPasswordTooShort = fmt.Errorf("password must be at least %d characters", MinPasswordLen)
ErrPasswordMismatch = errors.New("new password and confirmation do not match")
)
type Service struct {
store *store.Store
cfg *config.Config
}
func New(st *store.Store, cfg *config.Config) *Service {
return &Service{store: st, cfg: cfg}
}
// EnsureDefault seeds the admin/admin login on first run. Safe to call every
// startup — it's a no-op once a row exists.
func (s *Service) EnsureDefault() error {
hash, err := auth.HashPassword(DefaultPassword)
if err != nil {
return err
}
return s.store.EnsureAuth(DefaultUsername, hash)
}
// ResetToDefault restores admin/admin, forces a password change on next
// login, and disables MFA. Used by the `-userreset` CLI flag.
func (s *Service) ResetToDefault() error {
hash, err := auth.HashPassword(DefaultPassword)
if err != nil {
return err
}
return s.store.ResetAuth(DefaultUsername, hash)
}
// LoginStatus reports the current user's must-change/session-version/MFA
// state, used right after a password check to decide the login's next step.
func (s *Service) LoginStatus() (mustChange bool, sessionVersion int, mfaEnabled bool, err error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, 0, false, err
}
return rec.MustChangePassword, rec.SessionVersion, rec.MFAEnabled, nil
}
// pendingMFAVersion is an impossible session_version (real ones start at 1
// and only increase), so a pending-MFA token can never pass CheckSession and
// be used as a real session cookie even if it leaked.
const pendingMFAVersion = -1
const pendingMFATTL = 5 * time.Minute
// NewPendingMFAToken issues a short-lived token proving username+password
// were already verified, so the login page's MFA modal doesn't need the
// password resent alongside the code.
func (s *Service) NewPendingMFAToken(username string) string {
return auth.NewSessionToken(s.cfg.AppSecret, username, pendingMFAVersion, pendingMFATTL)
}
// CompleteMFALogin verifies a pending-MFA token plus TOTP code, returning
// the session data needed to issue the real login cookie.
func (s *Service) CompleteMFALogin(pendingToken, code string) (ok bool, username string, mustChange bool, sessionVersion int, err error) {
user, version, valid := auth.ParseSessionToken(s.cfg.AppSecret, pendingToken)
if !valid || version != pendingMFAVersion {
return false, "", false, 0, nil
}
rec, err := s.store.GetAuth()
if err != nil {
return false, "", false, 0, err
}
if rec.Username != user || !rec.MFAEnabled {
return false, "", false, 0, nil
}
secret, decErr := crypto.Decrypt(s.cfg.AppSecret, rec.MFASecretEnc)
if decErr != nil || !auth.ValidateTOTP(secret, code) {
return false, "", false, 0, nil
}
return true, rec.Username, rec.MustChangePassword, rec.SessionVersion, nil
}
// VerifyPassword checks password alone (no MFA), for re-authenticating
// before a credentials change — the user is already inside an authenticated
// session, so requiring a fresh TOTP code too would be redundant friction.
func (s *Service) VerifyPassword(username, password string) (bool, error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, err
}
return rec.Username == username && auth.VerifyPassword(password, rec.PasswordHash), nil
}
// SetCredentials updates username/password (used both for the forced
// first-login setup and later changes), clearing must-change and bumping
// the session version so every other signed-in cookie is invalidated. It
// returns the new session_version so the caller can issue a fresh cookie.
func (s *Service) SetCredentials(username, newPassword, confirmPassword string) (int, error) {
if !usernameRe.MatchString(username) {
return 0, ErrInvalidUsername
}
if len(newPassword) < MinPasswordLen {
return 0, ErrPasswordTooShort
}
if newPassword != confirmPassword {
return 0, ErrPasswordMismatch
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return 0, err
}
if err := s.store.SetAuthCredentials(username, hash); err != nil {
return 0, err
}
rec, err := s.store.GetAuth()
if err != nil {
return 0, err
}
return rec.SessionVersion, nil
}
func (s *Service) Username() (string, error) {
rec, err := s.store.GetAuth()
if err != nil {
return "", err
}
return rec.Username, nil
}
func (s *Service) MustChangePassword() (bool, error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, err
}
return rec.MustChangePassword, nil
}
func (s *Service) MFAEnabled() (bool, error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, err
}
return rec.MFAEnabled, nil
}
// BeginMFA generates a new TOTP secret and stores it (encrypted) as pending
// until confirmed with a code. Calling it again before confirming replaces
// the pending secret.
func (s *Service) BeginMFA() (secretBase32, otpauthURI string, err error) {
rec, err := s.store.GetAuth()
if err != nil {
return "", "", err
}
secret, err := auth.NewTOTPSecret()
if err != nil {
return "", "", err
}
secretEnc, err := crypto.Encrypt(s.cfg.AppSecret, secret)
if err != nil {
return "", "", err
}
if err := s.store.SetMFAPending(secretEnc); err != nil {
return "", "", err
}
return secret, auth.OTPAuthURI(issuer, rec.Username, secret), nil
}
// PendingMFASecret returns the not-yet-confirmed secret (for redisplay if
// the account page reloads mid-setup), or ok=false if there is none.
func (s *Service) PendingMFASecret() (secretBase32, otpauthURI string, ok bool, err error) {
rec, err := s.store.GetAuth()
if err != nil {
return "", "", false, err
}
if rec.MFASecretEnc == "" || rec.MFAEnabled {
return "", "", false, nil
}
secret, err := crypto.Decrypt(s.cfg.AppSecret, rec.MFASecretEnc)
if err != nil {
return "", "", false, err
}
return secret, auth.OTPAuthURI(issuer, rec.Username, secret), true, nil
}
func (s *Service) ConfirmMFA(code string) error {
secret, _, ok, err := s.PendingMFASecret()
if err != nil {
return err
}
if !ok {
return errors.New("no MFA setup in progress")
}
if !auth.ValidateTOTP(secret, code) {
return errors.New("invalid code")
}
return s.store.ConfirmMFA()
}
func (s *Service) DisableMFA() error {
return s.store.DisableMFA()
}
// NewSession issues a signed session token for the given username/version.
func (s *Service) NewSession(username string, version int) string {
return auth.NewSessionToken(s.cfg.AppSecret, username, version, SessionTTL)
}
// CheckSession verifies token's signature/expiry and that it still matches
// the current stored username/session_version (so a password change or
// -userreset immediately invalidates every previously issued cookie).
func (s *Service) CheckSession(token string) (valid, mustChange bool, err error) {
username, version, ok := auth.ParseSessionToken(s.cfg.AppSecret, token)
if !ok {
return false, false, nil
}
rec, err := s.store.GetAuth()
if err != nil {
return false, false, err
}
if rec.Username != username || rec.SessionVersion != version {
return false, false, nil
}
return true, rec.MustChangePassword, nil
}