Documentation
¶
Overview ¶
Package users — MFA (TOTP) enrollment storage on the User store.
This file extends Store with TOTP-based MFA. It treats the TOTP secret as T1 data (per docs/data-classification.md): AES-256-GCM encrypted at rest using the same HKDF-derived key the rest of T1 storage uses.
Scope: ADMIN ROLE ONLY in this slice. Per the user-direction in the brief ("Out of scope: Non-admin user MFA"), enrollment for non-admins is rejected at this layer rather than in the HTTP handler — defence in depth so a misconfigured route can't sidestep the gate.
All methods are thread-safe and follow the same lock-then-snapshot- then-DB pattern the password / role helpers in store.go use.
Package users — TOTP (Time-based One-Time Password) per RFC 6238.
Pure Go implementation built on HOTP (RFC 4226) with a 30-second time step and SHA-1 HMAC. SHA-1 is the spec-mandated default for TOTP and is used by every authenticator app (Google Authenticator, Authy, 1Password, etc.). The keyspace lives outside SHA-1's collision-relevant input domain — TOTP uses HMAC-SHA1, not bare SHA-1, so the SHA-1 collision history does not degrade the HOTP/TOTP security argument (NIST SP 800-63B §5.1.4 / RFC 6238 §5.1).
Validation accepts a ±1 step skew window (the call site can override) to tolerate clock drift between the authenticator app and the server. The 30-second step gives a 90-second total accept window with skew=1.
Provisioning URI follows the otpauth:// format documented at https://github.com/google/google-authenticator/wiki/Key-Uri-Format and is consumed unchanged by every popular authenticator app.
Index ¶
- Constants
- func GenerateTOTPCode(secret string, t time.Time) (string, error)
- func GenerateTOTPSecret() (string, error)
- func ProvisioningURI(secret, issuer, account string) string
- func VerifyTOTPCode(secret, code string, t time.Time, skewSteps int) bool
- type FamilyInvitation
- type InvitationStore
- func (s *InvitationStore) Accept(id string) error
- func (s *InvitationStore) CleanupExpired() int
- func (s *InvitationStore) Create(inv *FamilyInvitation) error
- func (s *InvitationStore) Get(id string) *FamilyInvitation
- func (s *InvitationStore) GetByInvitedEmail(email string) *FamilyInvitation
- func (s *InvitationStore) InitTable() error
- func (s *InvitationStore) ListByAdmin(adminEmail string) []*FamilyInvitation
- func (s *InvitationStore) LoadFromDB() error
- func (s *InvitationStore) Revoke(id string) error
- type Store
- func (s *Store) ClearTOTPSecret(email string) error
- func (s *Store) Count() int
- func (s *Store) Create(u *User) error
- func (s *Store) Delete(email string)
- func (s *Store) EnsureAdmin(email string)
- func (s *Store) EnsureGoogleUser(email string)
- func (s *Store) EnsureUser(email, kiteUID, displayName, onboardedBy string) *User
- func (s *Store) Exists(email string) bool
- func (s *Store) Get(email string) (*User, bool)
- func (s *Store) GetByEmail(email string) (*User, bool)
- func (s *Store) GetRole(email string) string
- func (s *Store) GetStatus(email string) string
- func (s *Store) GetTOTPSecret(email string) (string, bool)
- func (s *Store) HasPassword(email string) bool
- func (s *Store) HasTOTP(email string) bool
- func (s *Store) InitTable() error
- func (s *Store) IsAdmin(email string) bool
- func (s *Store) List() []*User
- func (s *Store) ListByAdminEmail(adminEmail string) []*User
- func (s *Store) LoadFromDB() error
- func (s *Store) SetAdminEmail(email, adminEmail string) error
- func (s *Store) SetDB(db *alerts.DB)
- func (s *Store) SetEncryptionKey(key []byte)
- func (s *Store) SetLogger(logger *slog.Logger)
- func (s *Store) SetPasswordHash(email, hash string) error
- func (s *Store) SetTOTPSecret(email, plaintextSecret string) error
- func (s *Store) TOTPEnrolledAt(email string) (time.Time, bool)
- func (s *Store) UpdateKiteUID(email, kiteUID string)
- func (s *Store) UpdateLastLogin(email string)
- func (s *Store) UpdateRole(email, role string) error
- func (s *Store) UpdateStatus(email, status string) error
- func (s *Store) VerifyPassword(email, password string) (bool, error)
- func (s *Store) VerifyTOTP(email, code string) (bool, error)
- type User
- func (u *User) BelongsToAdmin(adminEmail string) bool
- func (u *User) CanBeOnboardedByGoogleSSO() bool
- func (u *User) CanTrade() bool
- func (u *User) HasPassword() bool
- func (u *User) IsActive() bool
- func (u *User) IsAdmin() bool
- func (u *User) IsBillingOwner() bool
- func (u *User) IsFamilyMember() bool
- func (u *User) IsOffboarded() bool
- func (u *User) IsSuspended() bool
- func (u *User) IsTrader() bool
- func (u *User) IsViewer() bool
Constants ¶
const ( RoleAdmin = "admin" RoleTrader = "trader" RoleViewer = "viewer" )
Role constants for user access control.
const ( StatusActive = "active" StatusSuspended = "suspended" StatusOffboarded = "offboarded" )
Status constants for user lifecycle.
const ( // TOTPDigits is the number of digits in a TOTP code. RFC 6238 §5.3 specifies 6. TOTPDigits = 6 // TOTPPeriodSeconds is the time step in seconds. RFC 6238 §5.2 default is 30. TOTPPeriodSeconds = 30 // TOTPSecretBytes is the length of a freshly generated TOTP secret in bytes. // 20 bytes = 160 bits — RFC 4226 §4 minimum is 128 bits; 160 is the SHA-1 // block-aligned value used by Google Authenticator. TOTPSecretBytes = 20 // TOTPSkewSteps is the default ± window of accepted time steps. // 1 step = ±30s = 90s total accept window. Tolerates routine clock drift. TOTPSkewSteps = 1 )
TOTP parameters per RFC 6238 — the values every authenticator app expects.
Variables ¶
This section is empty.
Functions ¶
func GenerateTOTPCode ¶
GenerateTOTPCode returns the TOTP code for the given secret at time t. The secret must be base32-encoded (the form returned by GenerateTOTPSecret and stored in the database). Empty / invalid secrets return an error rather than a zero code so call sites cannot silently miss enrollment.
func GenerateTOTPSecret ¶
GenerateTOTPSecret produces a fresh 160-bit secret suitable for TOTP enrollment. The returned string is the base32-encoded, no-padding form that authenticator apps expect.
func ProvisioningURI ¶
ProvisioningURI returns an otpauth:// URI for QR-code enrollment. issuer is the project name shown in the authenticator app; account is usually the user's email. The URI format is the de-facto standard documented at https://github.com/google/google-authenticator/wiki/Key-Uri-Format — every popular authenticator app (Google Authenticator, Authy, 1Password, Microsoft Authenticator, Bitwarden) consumes it unchanged.
func VerifyTOTPCode ¶
VerifyTOTPCode returns true if the supplied code matches the TOTP for the given secret at time t, allowing ± skewSteps of clock drift. The comparison is constant-time. An empty code or empty secret returns false without erroring, so call sites can use this as a single-line gate.
Types ¶
type FamilyInvitation ¶
type FamilyInvitation struct {
ID string `json:"id"`
AdminEmail string `json:"admin_email"`
InvitedEmail string `json:"invited_email"`
Status string `json:"status"` // pending, accepted, expired, revoked
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt time.Time `json:"accepted_at,omitempty"`
}
type InvitationStore ¶
type InvitationStore struct {
// contains filtered or unexported fields
}
func NewInvitationStore ¶
func NewInvitationStore(db *alerts.DB) *InvitationStore
func (*InvitationStore) Accept ¶
func (s *InvitationStore) Accept(id string) error
func (*InvitationStore) CleanupExpired ¶
func (s *InvitationStore) CleanupExpired() int
CleanupExpired marks all pending invitations past their expiry as expired. Call periodically (e.g., daily).
func (*InvitationStore) Create ¶
func (s *InvitationStore) Create(inv *FamilyInvitation) error
func (*InvitationStore) Get ¶
func (s *InvitationStore) Get(id string) *FamilyInvitation
func (*InvitationStore) GetByInvitedEmail ¶
func (s *InvitationStore) GetByInvitedEmail(email string) *FamilyInvitation
func (*InvitationStore) InitTable ¶
func (s *InvitationStore) InitTable() error
func (*InvitationStore) ListByAdmin ¶
func (s *InvitationStore) ListByAdmin(adminEmail string) []*FamilyInvitation
func (*InvitationStore) LoadFromDB ¶
func (s *InvitationStore) LoadFromDB() error
func (*InvitationStore) Revoke ¶
func (s *InvitationStore) Revoke(id string) error
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is a thread-safe in-memory user store backed by SQLite.
func (*Store) ClearTOTPSecret ¶
ClearTOTPSecret removes a user's TOTP enrollment. Used by an admin recovery flow (lost-device) and by the offboarding path. Returns error if the user is not found.
func (*Store) Create ¶
Create inserts a new user into the store. Returns error if user already exists.
func (*Store) EnsureAdmin ¶
EnsureAdmin creates or updates a user to have admin role. Used to seed admin users from ADMIN_EMAILS env var at startup.
func (*Store) EnsureGoogleUser ¶
EnsureGoogleUser auto-creates a trader account on first Google SSO login. Existing users are left unchanged (admins keep their admin role).
func (*Store) EnsureUser ¶
EnsureUser creates a user if they don't exist, returning the user. Used for auto-provisioning on first OAuth login.
func (*Store) GetByEmail ¶
GetByEmail is an alias for Get (for interface clarity).
func (*Store) GetStatus ¶
GetStatus returns the user's status. Returns empty string if user not found.
func (*Store) GetTOTPSecret ¶
GetTOTPSecret returns the decrypted TOTP secret for the given user. Returns ("", false) if the user is not enrolled or unknown, or if decryption fails (likely key rotation drift). The bool reports "successfully retrieved a usable secret"; callers MUST treat false as "not enrolled" and not as an error condition.
func (*Store) HasPassword ¶
HasPassword returns true if the given user has a non-empty password hash. Delegates to User.HasPassword() for the actual check.
func (*Store) HasTOTP ¶
HasTOTP returns true if the user has an enrolled TOTP secret. Unknown users return false (no error path) so the call site can use this as a single-line gate.
func (*Store) IsAdmin ¶
IsAdmin returns true if the given email belongs to an admin user. Delegates to User.IsAdmin() for the actual check.
func (*Store) ListByAdminEmail ¶
ListByAdminEmail returns all users linked to this admin.
func (*Store) LoadFromDB ¶
LoadFromDB populates the in-memory store from the database.
func (*Store) SetAdminEmail ¶
SetAdminEmail links a user to their admin for family billing tier inheritance.
func (*Store) SetDB ¶
func (s *Store) SetDB(db *alerts.DB)
SetDB enables write-through persistence to the given SQLite database.
func (*Store) SetEncryptionKey ¶
SetEncryptionKey wires the AES-256 key used to encrypt TOTP secrets at rest. The caller (composition root in app/wire.go) is expected to supply the same HKDF-derived key the rest of the alerts.DB uses, so rotation via the existing migrateEncryptedData path stays one operation.
Calling SetTOTPSecret without first calling SetEncryptionKey is a programming error and returns an explicit error rather than silently storing plaintext — silent plaintext storage would be a T1 protection regression, hence fail-closed.
func (*Store) SetPasswordHash ¶
SetPasswordHash stores a bcrypt password hash for the given user.
func (*Store) SetTOTPSecret ¶
SetTOTPSecret encrypts and persists a TOTP secret for the given user. Returns error in three cases:
- encryption key not configured (store-level misconfig)
- user not found (call site bug — enroll route should always pre-check)
- user is not an admin (slice-1 invariant: MFA is admin-only here)
func (*Store) TOTPEnrolledAt ¶
TOTPEnrolledAt returns the enrollment timestamp for the user. Returns (zero, false) if the user is not enrolled or unknown.
func (*Store) UpdateKiteUID ¶
UpdateKiteUID sets the Kite user ID for a user.
func (*Store) UpdateLastLogin ¶
UpdateLastLogin records the current time as the user's last login.
func (*Store) UpdateRole ¶
UpdateRole changes the user's role. Returns error if user not found or invalid role.
func (*Store) UpdateStatus ¶
UpdateStatus changes the user's status. Returns error if user not found or invalid status.
func (*Store) VerifyPassword ¶
VerifyPassword checks the given plaintext password against the stored bcrypt hash. Returns (true, nil) on match, (false, nil) on mismatch, (false, error) on lookup failure. For timing safety, always runs bcrypt comparison even for unknown users.
func (*Store) VerifyTOTP ¶
VerifyTOTP returns (true, nil) if the supplied 6-digit code matches the user's stored TOTP secret within the default skew window. Returns (false, nil) for any non-match (wrong code, not enrolled, unknown user). Returns a non-nil error only on infrastructure failure (encryption key missing). Callers should route a not-enrolled user to enrollment, not treat the false as authentication failure.
type User ¶
type User struct {
ID string `json:"id"`
Email string `json:"email"`
KiteUID string `json:"kite_uid,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Role string `json:"role"`
Status string `json:"status"`
PasswordHash string `json:"-"` // bcrypt hash, never serialized
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLogin time.Time `json:"last_login,omitempty"`
OnboardedBy string `json:"onboarded_by"`
AdminEmail string `json:"admin_email,omitempty"`
// TOTPSecretEnc is the AES-256-GCM-encrypted TOTP secret (RFC 6238)
// for admin MFA. Empty when not enrolled. Never serialised to JSON
// — the MFA enrollment endpoint returns the plaintext exactly once
// at enrollment time, never afterwards. See kc/users/mfa.go.
TOTPSecretEnc string `json:"-"`
TOTPEnrolledAt time.Time `json:"totp_enrolled_at,omitempty"`
}
User represents a registered user of the system.
func (*User) BelongsToAdmin ¶
BelongsToAdmin returns true if the user is linked to the given admin email as a family member.
func (*User) CanBeOnboardedByGoogleSSO ¶
CanBeOnboardedByGoogleSSO returns true if the user was auto-provisioned via Google SSO (vs. invited or seeded from env).
func (*User) CanTrade ¶
CanTrade returns true if this user is allowed to place trades (not a viewer, and active).
func (*User) HasPassword ¶
HasPassword returns true if this user has a non-empty password hash.
func (*User) IsBillingOwner ¶
IsBillingOwner returns true if the user is their own billing owner (either an admin or a self-onboarded user with no parent admin).
func (*User) IsFamilyMember ¶
IsFamilyMember returns true if the user belongs to a family billing group (has an admin email set) and is therefore not the billing owner.
func (*User) IsOffboarded ¶
IsOffboarded returns true if the user's account has been offboarded.
func (*User) IsSuspended ¶
IsSuspended returns true if the user's account is suspended.