// Package mfa: multi-factor authentication — phone enrollment, per-tenant policy, // and the login-time SMS challenge. package mfa import ( "context" "errors" "atlas9.dev/c/core" "atlas9.dev/c/core/iam" ) // Enrollment is a user's second factor: a phone number that texts them a code. // Verified is set only once a code sent to Phone has been confirmed. type Enrollment struct { UserID core.ID Phone string Verified bool } // EnrollmentStore holds one enrollment per user. Self-service: a user reads and // writes their own enrollment without a cap, like profiles; another principal // (or the pre-auth login flow) needs the cap scoped to the user. type EnrollmentStore interface { Get(ctx context.Context, userID core.ID) (*Enrollment, error) Save(ctx context.Context, e *Enrollment) error Delete(ctx context.Context, userID core.ID) error } // ChallengeStore issues and verifies the short numeric codes texted at login and // during enrollment. Unlike tokens.Store it keeps an attempt counter, so a single // wrong digit does not discard the challenge. type ChallengeStore interface { // Create issues a challenge for userID and returns its opaque id (the handle // the client echoes back) and the plaintext code (to be texted, never stored // in the clear). Create(ctx context.Context, userID core.ID) (id, code string, err error) // Verify checks code against the challenge id. On success it deletes the // challenge and returns the user it was issued for. A wrong code increments // the attempt counter and returns ErrInvalidCode; past MaxAttempts the // challenge is deleted. A missing/expired challenge returns core.ErrNotFound. Verify(ctx context.Context, id, code string) (core.ID, error) DeleteExpired(ctx context.Context) (int64, error) } // PolicyStore records which tenants require MFA of their members. type PolicyStore interface { // Get reports whether the tenant requires MFA. Get(ctx context.Context, tenant core.ID) (bool, error) // Set turns the tenant's MFA requirement on or off. Set(ctx context.Context, tenant core.ID, required bool) error // RequiredForUser reports whether any tenant the user belongs to requires // MFA. Answered on the system plane at login, before a session exists. RequiredForUser(ctx context.Context, userID core.ID) (bool, error) } var ( // Enrollment read/write. Self-access needs none (subject match); the login // flow and operators use these on the system plane / scoped to the user. Cap_Mfa_Read = iam.NewCap("Mfa_Read") Cap_Mfa_Write = iam.NewCap("Mfa_Write") // Challenge create/verify — always system plane (login has no principal yet). Cap_Mfa_Challenge = iam.NewCap("Mfa_Challenge") // Per-tenant policy, scoped to the tenant. Owners hold these. Cap_Mfa_PolicyRead = iam.NewCap("Mfa_PolicyRead") Cap_Mfa_PolicyWrite = iam.NewCap("Mfa_PolicyWrite") ) // ErrInvalidCode is returned when a submitted code does not match the challenge. var ErrInvalidCode = errors.New("invalid verification code") // MaxAttempts is how many wrong codes a single challenge tolerates before it is // discarded and the user must restart. const MaxAttempts = 5