// 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 }