Documentation
¶
Overview ¶
Package twofactor adds TOTP second factors (authenticator apps) with single-use recovery codes.
It uses only core mechanisms any plugin has: a column on auth_user (two_factor_enabled, so "is it on" costs no join), tables of its own, the SecondFactor interface that makes sign-in sessions pending, and a PendingSecondFactor operation that promotes the session. No sign-in plugin knows it exists.
Index ¶
- Constants
- Variables
- func AskEverySignIn(_ context.Context, in PolicyRequest) (bool, error)
- func Bindings(c *TwoFactorColumns) []sqlb.Binding
- func DefaultPolicy(_ context.Context, in PolicyRequest) (bool, error)
- type CodeRequest
- type DevicesResult
- type EnrollRequest
- type EnrollResult
- type EnrolmentRule
- type Factor
- type FactorProof
- type ForgetDeviceRequest
- type LabelFunc
- type Method
- type Option
- func AccountLabel(fn LabelFunc) Option
- func CodeTTL(d time.Duration) Option
- func DeliveredCodes() Option
- func Issuer(name string) Option
- func Lockout(maxFailures int, d time.Duration) Option
- func ReadableTOTPURI() Option
- func RecoveryCodeCount(n int) Option
- func RecoveryCodeGenerator(fn func() []string) Option
- func RecoveryCodeLength(n int) Option
- func RecoveryCodeStorage(s RecoveryStorage) Option
- func RequireEnrolment(rule EnrolmentRule) Option
- func RequirePassword() Option
- func SkipVerificationOnEnable() Option
- func TrustDevices(maxAge time.Duration) Option
- func WithDigits(n int) Option
- func WithEncryptionKey(key []byte) Option
- func WithPeriod(d time.Duration) Option
- func WithPolicy(fn Policy) Option
- func WithSealer(s auth.Sealer) Option
- func WithSecurityKeys(s SecurityKeyStore) Option
- func WithoutTOTP() Option
- type PasswordChecker
- type PasswordRequest
- type Plugin
- func (p *Plugin) CheckCode(ctx context.Context, code string) error
- func (p *Plugin) Columns() []auth.ColumnExtension
- func (p *Plugin) Confirm(ctx context.Context, code string) (RecoveryCodes, error)
- func (p *Plugin) CredentialChanged(ctx context.Context, userID string) error
- func (p *Plugin) Devices(ctx context.Context) ([]TrustedDevice, error)
- func (p *Plugin) Disable(ctx context.Context, code string) error
- func (p *Plugin) DisableFor(ctx context.Context, userID string) error
- func (p *Plugin) DisableWith(ctx context.Context, in CodeRequest) error
- func (p *Plugin) EnableFor(ctx context.Context, userID string) error
- func (p *Plugin) Enroll(ctx context.Context) (EnrollResult, error)
- func (p *Plugin) EnrollWith(ctx context.Context, in EnrollRequest) (EnrollResult, error)
- func (p *Plugin) FactorOf(ctx context.Context, userID string) (method string, enabled bool, err error)
- func (p *Plugin) ForgetDevice(ctx context.Context, in ForgetDeviceRequest) error
- func (p *Plugin) ImportTOTP(ctx context.Context, userID string, secret string) error
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) Methods() []Method
- func (p *Plugin) Name() string
- func (p *Plugin) Operations() []auth.Operation
- func (p *Plugin) OptionErr() error
- func (p *Plugin) RateLimits() []auth.RateRule
- func (p *Plugin) Reauthenticate(ctx context.Context, in ReauthenticateRequest) (auth.Reauthentication, error)
- func (p *Plugin) RegenerateRecoveryCodes(ctx context.Context, code string) (RecoveryCodes, error)
- func (p *Plugin) RegenerateRecoveryCodesWith(ctx context.Context, in CodeRequest) (RecoveryCodes, error)
- func (p *Plugin) RemoveFactor(ctx context.Context, in RemoveFactorRequest) (RemoveFactorResult, error)
- func (p *Plugin) Reset(ctx context.Context, userID string) error
- func (p *Plugin) SecondFactorRequired(ctx context.Context, userID string) (bool, error)
- func (p *Plugin) SecondFactorRequiredForSignIn(ctx context.Context, in auth.SignInAttempt) (bool, error)
- func (p *Plugin) SecurityKeyChallengeFor(ctx context.Context) (SecurityKeyChallenge, error)
- func (p *Plugin) SecurityKeyRegisterBegin(ctx context.Context, in SecurityKeyRegisterBeginRequest) (SecurityKeyChallenge, error)
- func (p *Plugin) SecurityKeyRegisterFinish(ctx context.Context, in SecurityKeyRegisterFinishRequest) (SecurityKeyRegistered, error)
- func (p *Plugin) Send(ctx context.Context) error
- func (p *Plugin) SessionTasks() []auth.SessionTask
- func (p *Plugin) SessionTasksFor(ctx context.Context, in auth.SignInAttempt) ([]auth.SessionTaskID, error)
- func (p *Plugin) Status(ctx context.Context) (Status, error)
- func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)
- func (p *Plugin) TOTPURI(ctx context.Context, in PasswordRequest) (TOTPURIResult, error)
- func (p *Plugin) Tables() []sqlb.Table
- func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)
- func (p *Plugin) Unlock(ctx context.Context, userID string) error
- func (p *Plugin) UserTables() []auth.UserTable
- func (p *Plugin) Validate(cfg *auth.Config) error
- func (p *Plugin) Verify(ctx context.Context, code string) error
- func (p *Plugin) VerifyWith(ctx context.Context, in CodeRequest) error
- func (p *Plugin) ViewRecoveryCodes(ctx context.Context, in PasswordRequest) (RecoveryCodes, error)
- type Policy
- type PolicyRequest
- type ReauthenticateRequest
- type RecoveryCodes
- type RecoveryStorage
- type RemoveFactorRequest
- type RemoveFactorResult
- type SecurityKey
- type SecurityKeyChallenge
- type SecurityKeyRegisterBeginRequest
- type SecurityKeyRegisterFinishRequest
- type SecurityKeyRegistered
- type SecurityKeyRegistration
- type SecurityKeyResponse
- type SecurityKeyStore
- type Status
- type TOTPURIResult
- type TrustedDevice
- type TwoFactorColumns
Constants ¶
const ( // EndpointDevices lists the signed-in user's trusted devices. EndpointDevices auth.EndpointID = "twofactor.devices" // EndpointForgetDevice revokes one trusted device, or all of them. EndpointForgetDevice auth.EndpointID = "twofactor.forget-device" )
Operation ids and paths of the device operations.
const ( //ds:def id=tablenamedevice-x4m9kq7v owner=@khanakia stability=stable TableNameDevice = "auth_two_factor_device" TableAliasDevice = "tfd" ColDeviceID = "id" ColDeviceUser = "user_id" ColDeviceTokenHash = "token_hash" ColDeviceUserAgent = "user_agent" ColDeviceCreatedAt = "created_at" ColDeviceExpiresAt = "expires_at" ColDeviceLastUsed = "last_used_at" )
The device table: one row per trusted device, looked up by token hash at sign-in and by user for the list.
const ( // DefaultTrustedDeviceAge is how long a trusted device skips the second // factor: thirty days, the figure users expect from "remember this // browser", and the age is fixed at trust time, never renewed by use, // so a stolen cookie has a known end. //ds:def id=defaulttrusteddeviceage-m2k7xn4q owner=@khanakia stability=stable DefaultTrustedDeviceAge = 30 * 24 * time.Hour // MaxTrustedDeviceAge bounds TrustDevices: a year of skipping the second // factor is no longer two-factor sign-in. //ds:def id=maxtrusteddeviceage-8gw3rz6p owner=@khanakia stability=stable MaxTrustedDeviceAge = 365 * 24 * time.Hour )
Trusted-device lifetimes.
const ( // EndpointSecurityKeyChallenge starts the WebAuthn assertion a pending // sign-in (or a signed-in step-up) answers with a security key. EndpointSecurityKeyChallenge auth.EndpointID = "twofactor.security-key-challenge" // EndpointSecurityKeyRegisterBegin and EndpointSecurityKeyRegisterFinish // add a security key as a second factor. EndpointSecurityKeyRegisterBegin auth.EndpointID = "twofactor.security-key-register-begin" EndpointSecurityKeyRegisterFinish auth.EndpointID = "twofactor.security-key-register-finish" // EndpointRemoveFactor removes one factor; removing the last turns // two-factor sign-in off, as disable does. EndpointRemoveFactor auth.EndpointID = "twofactor.remove-factor" )
Operation ids of the multi-factor operations.
const ( //ds:def id=tablenamemethod-8vq3kd7w owner=@khanakia stability=stable TableNameMethod = "auth_two_factor_method" TableAliasMethod = "tfm" )
The table of a user's further code factors, one row per method.
const ( // Step is the default RFC 6238 time step. //ds:def id=step-xcvm8nc8 owner=@khanakia stability=stable Step = 30 * time.Second // Digits is the default code length. //ds:def id=digits-j3q4e5tz owner=@khanakia stability=stable Digits = 6 // LongDigits is the other code length RFC 6238 allows and authenticator // apps accept. //ds:def id=longdigits-g9we2d29 owner=@khanakia stability=stable LongDigits = 8 // Skew is how many steps before and after now are accepted, for a phone // whose clock is a little off. //ds:def id=skew-v9xvtnr8 owner=@khanakia stability=stable Skew = 1 )
TOTP parameters. The defaults are the values every authenticator app supports; Period and Digits change them for an app that must, and changing either after users enrolled breaks their apps, because the values are written into the app at enrolment.
const ( EndpointEnroll auth.EndpointID = "twofactor.enroll" EndpointConfirm auth.EndpointID = "twofactor.confirm" EndpointDisable auth.EndpointID = "twofactor.disable" EndpointVerify auth.EndpointID = "twofactor.verify" // EndpointRecoveryCodes replaces the user's recovery codes. EndpointRecoveryCodes auth.EndpointID = "twofactor.recovery-codes" // EndpointSend mails or texts a one-time code to a user whose factor is // MethodDelivered, for the sign-in step and before disable or regenerate. EndpointSend auth.EndpointID = "twofactor.send" // EndpointStatus tells a client whether the user has a confirmed factor // and which method it uses, so the page after a pending sign-in shows // the right form (code from the app, or "we sent you a code") without // probing send and reading ErrNotCodeMethod. EndpointStatus auth.EndpointID = "twofactor.status" // EndpointCheck checks a code for a signed-in user without touching the // session: a step-up before a sensitive action (better-auth's verify on // an already signed-in session). Same lockout and rate rule as verify. EndpointCheck auth.EndpointID = "twofactor.check" // EndpointReauthenticate is the second step of a re-authentication: a // current code or a recovery code plus the ticket the first step // (password.reauthenticate, passkey.reauthenticate-finish) answered with, // after which the signed-in session is fresh again. Same lockout as // verify. EndpointReauthenticate auth.EndpointID = "twofactor.reauthenticate" // EndpointTOTPURI re-reads the otpauth:// URI of the user's // authenticator-app factor (better-auth's get-totp-uri). It exists only // with ReadableTOTPURI. EndpointTOTPURI auth.EndpointID = "twofactor.totp-uri" // EndpointViewRecoveryCodes shows the user's unused recovery codes again // (better-auth's view-backup-codes). It exists only with // RecoveryCodeStorage(StoreEncrypted), since hashed codes cannot be shown. EndpointViewRecoveryCodes auth.EndpointID = "twofactor.view-recovery-codes" )
Operation ids, the permanent names hooks, rate rules and transports address the plugin's operations by.
const ( //ds:def id=tablenamefactor-qk9j7b3v owner=@khanakia stability=stable TableNameFactor = "auth_two_factor" TableAliasFactor = "tf" //ds:def id=tablenamerecovery-74y7xq39 owner=@khanakia stability=stable TableNameRecovery = "auth_two_factor_recovery" TableAliasRecover = "tfr" ColUserID = "user_id" ColSecret = "secret_sealed" ColConfirmedAt = "confirmed_at" ColLastStep = "last_step" ColFailures = "failed_attempts" ColLockedUntil = "locked_until" ColCreatedAt = "created_at" // ColMethod is the factor's Method; "totp" for every row made before the // column existed, which is what its default says. ColMethod = "method" // ColIssuer is the issuer the user enrolled with when EnrollRequest named // one (better-auth's per-call issuer); "" means the plugin's Issuer, and // is what every row made before the column existed holds. ColIssuer = "issuer" ColRecoveryID = "id" ColRecoveryUser = "user_id" ColRecoveryHash = "code_hash" ColRecoveryUsed = "used_at" // ColRecoverySealed holds the code itself, sealed, under // RecoveryCodeStorage(StoreEncrypted) so view-recovery-codes can show it; // "" under the default hashed storage and once the code is used. ColRecoverySealed = "code_sealed" // ColRecoverySeq is a code's place in the set as it was handed out // (0, 1, 2, …), so view-recovery-codes shows the set in the order the // user printed it. The id is random and cannot order it. Every row made // before the column existed holds 0, and those sort by id: a stable // order, though not the original one. ColRecoverySeq = "seq" // ColEnabled is the column this plugin adds to auth_user. //ds:def id=colenabled-n2n83a62 owner=@khanakia stability=stable ColEnabled = "two_factor_enabled" )
Tables and columns. auth_two_factor holds one factor per user, created unconfirmed by enroll and confirmed by confirm; auth_two_factor_recovery holds the hashes of the user's single-use recovery codes.
const ( // DefaultIssuer is the app name authenticator apps show without Issuer. //ds:def id=defaultissuer-enheudet owner=@khanakia stability=stable DefaultIssuer = "App" // DefaultRecoveryCodes is how many single-use codes confirm hands out: // enough for years of lost phones, few enough to print on one card. //ds:def id=defaultrecoverycodes-qp2yzv4v owner=@khanakia stability=stable DefaultRecoveryCodes = 10 // MaxFailures wrong codes lock the factor for LockDuration. A six-digit // code accepts 3 of a million values per window, so the lock is what // makes guessing hopeless, from any number of IPs. //ds:def id=maxfailures-dyu8kjqs owner=@khanakia stability=stable MaxFailures = 5 //ds:def id=lockduration-mh4x5jpc owner=@khanakia stability=stable LockDuration = 15 * time.Minute // DefaultRecoveryCodeLength is a recovery code's length in base32 // characters: 16 characters carry 80 bits, far beyond what the lockout // lets anyone guess, and read as four groups of four. //ds:def id=defaultrecoverycodelength-trh532bc owner=@khanakia stability=stable DefaultRecoveryCodeLength = 16 // MinRecoveryCodeLength is the shortest RecoveryCodeLength accepted: 8 // base32 characters are 40 bits, which the lockout still makes hopeless // to guess but a shorter code would not be. //ds:def id=minrecoverycodelength-k2frj7m4 owner=@khanakia stability=stable MinRecoveryCodeLength = 8 // DefaultCodeTTL is how long a delivered code works: long enough for a // slow mailbox, short enough that an intercepted message is stale. //ds:def id=defaultcodettl-2wq9hz5r owner=@khanakia stability=stable DefaultCodeTTL = 5 * time.Minute // MaxIssuerLength bounds a per-enrolment EnrollRequest.Issuer, in bytes: // long enough for any product name, short enough that a client cannot // store an essay in the factor row or the user's authenticator app. //ds:def id=maxissuerlength-r7u2kd9w owner=@khanakia stability=stable MaxIssuerLength = 64 )
Defaults and limits.
const KeyLabelRecoveryCodes = "twofactor-recovery-codes"
KeyLabelRecoveryCodes is the label the sealing key of StoreEncrypted is derived under, from auth.WithSecret. Fixed, so a rotation of the secret (current first, previous after) keeps every sealed code opening; distinct from the TOTP secrets' auth.KeyLabelTwoFactor, so the two never share a key.
const MinImportSecretBytes = 10
MinImportSecretBytes is the shortest TOTP secret ImportTOTP accepts: 80 bits, the 16-character base32 secrets the oldest authenticator deployments issued. RFC 4226 recommends 160 bits (what Enroll makes), but an import that refused shorter real secrets would lock those users out of a factor they already use.
const PluginName = "twofactor"
PluginName is the plugin's permanent identity. It owns the plugin's rows in the migration ledger and its tables and columns, so renaming it would make the next migrate see a new plugin; it never changes.
const PurposeCode auth.Purpose = "twofactor.code"
PurposeCode is the message purpose of a delivered second-factor code, and the purpose of its core one-time code; the subject of the code is the user id. Delivering plugins word it with the message's DefaultSubject and DefaultText unless the app's templates handle the purpose.
Variables ¶
var ( TableDevice = sqlb.Table{Name: TableNameDevice, Alias: TableAliasDevice} DeviceID = sqlb.Col[string](TableDevice, ColDeviceID) DeviceUser = sqlb.Col[string](TableDevice, ColDeviceUser) DeviceTokenHash = sqlb.Col[[]byte](TableDevice, ColDeviceTokenHash) DeviceUserAgent = sqlb.Col[string](TableDevice, ColDeviceUserAgent) DeviceCreatedAt = sqlb.Col[time.Time](TableDevice, ColDeviceCreatedAt) DeviceExpiresAt = sqlb.Col[time.Time](TableDevice, ColDeviceExpiresAt) DeviceLastUsed = sqlb.Col[*time.Time](TableDevice, ColDeviceLastUsed) )
Typed columns of the device table, for an app that reads it.
var ( // ErrTrustDisabled: a verify asked to trust the device, but the plugin // was built without TrustDevices. Refused rather than ignored, so a // client that offers the checkbox learns the server will not honour it. ErrTrustDisabled = auth.NewError("twofactor.trust_disabled", http.StatusBadRequest, "Remembering this device is not available.") // ErrDeviceNotFound: forget named a device that is not the user's. ErrDeviceNotFound = auth.NewError("twofactor.device_not_found", http.StatusNotFound, "Not found.") // ErrBadTrustAge: TrustDevices was given no duration, or more than // MaxTrustedDeviceAge. ErrBadTrustAge = errors.New("twofactor: TrustDevices needs a positive duration of at most MaxTrustedDeviceAge") )
Errors of the device operations and option.
var ( TableMethod = sqlb.Table{Name: TableNameMethod, Alias: TableAliasMethod} MethodUserID = sqlb.Col[string](TableMethod, ColUserID) MethodKind = sqlb.Col[string](TableMethod, ColMethod) MethodSecret = sqlb.Col[string](TableMethod, ColSecret) MethodIssuer = sqlb.Col[string](TableMethod, ColIssuer) MethodLastStep = sqlb.Col[int64](TableMethod, ColLastStep) MethodConfirmedAt = sqlb.Col[*time.Time](TableMethod, ColConfirmedAt) MethodCreatedAt = sqlb.Col[time.Time](TableMethod, ColCreatedAt) )
Typed columns of auth_two_factor_method. The column names are the primary row's (ColUserID, ColMethod, ColSecret, ColIssuer, ColLastStep, ColConfirmedAt, ColCreatedAt): a further factor carries the same facts, without the lockout, which stays on the primary row.
var ( // ErrFactorNotEnrolled: the request names a factor the user does not // have (a method never enrolled, or one removed). Not counted as a wrong // code: no secret was tried. ErrFactorNotEnrolled = auth.NewError("twofactor.factor_not_enrolled", http.StatusBadRequest, "This two-factor method is not set up for your account.") // ErrFactorNotFound: remove-factor named a security key id that is not // one of the user's keys, or a method the user does not have. ErrFactorNotFound = auth.NewError("twofactor.factor_not_found", http.StatusNotFound, "Not found.") // ErrProofRequired: adding or removing a factor while two-factor sign-in // is on needs a current factor (a code, a recovery code or a security // key), or a stolen fresh session could plant its own factor. ErrProofRequired = auth.NewError("twofactor.proof_required", http.StatusBadRequest, "Confirm with your current two-factor method first.") // ErrSecurityKeyInvalid answers an assertion that does not verify: a // wrong or replayed signature, a key of another user, a removed key, an // expired or spent ceremony. A SecurityKeyStore returns it (wrapped or // not) for every refusal; twofactor counts it as a wrong code. ErrSecurityKeyInvalid = auth.NewError("twofactor.security_key_invalid", http.StatusUnauthorized, "This security key could not be verified.") )
Errors of the multi-factor operations.
var ( // ErrBadRecoveryStorage: RecoveryCodeStorage got a value that is not one // of RecoveryStorageValues. ErrBadRecoveryStorage = errors.New("twofactor: RecoveryCodeStorage must be StoreHashed or StoreEncrypted") // ErrRecoveryStorageNeedsSecret: StoreEncrypted derives its key from // auth.WithSecret, and the app passed none. ErrRecoveryStorageNeedsSecret = errors.New("twofactor: RecoveryCodeStorage(StoreEncrypted) needs auth.WithSecret") // ErrRecoveryCodesHidden: an unused code of the user has no sealed copy // (it was made before StoreEncrypted was turned on), so the set cannot be // shown whole; recovery-codes makes a new one that can. ErrRecoveryCodesHidden = auth.NewError("twofactor.recovery_codes_hidden", http.StatusConflict, "These recovery codes cannot be shown again. Make a new set.") )
Errors of recovery-code storage.
var ( // ErrNotTOTP: totp-uri was asked for a factor that is not an // authenticator app; a delivered-code factor has no URI. ErrNotTOTP = auth.NewError("twofactor.not_totp", http.StatusConflict, "Your second factor is not an authenticator app.") // ErrTrustNeedsSession: verify was asked to trust the device and to leave // the session pending in one call. A device is trusted for a sign-in, so // the pair is refused rather than half done. ErrTrustNeedsSession = auth.NewError("twofactor.trust_needs_session", http.StatusBadRequest, "A device can only be remembered when signing in.") )
Errors of the step-up operations.
var ( TableFactor = sqlb.Table{Name: TableNameFactor, Alias: TableAliasFactor} TableRecovery = sqlb.Table{Name: TableNameRecovery, Alias: TableAliasRecover} FactorUserID = sqlb.Col[string](TableFactor, ColUserID) FactorSecret = sqlb.Col[string](TableFactor, ColSecret) FactorConfirmedAt = sqlb.Col[*time.Time](TableFactor, ColConfirmedAt) FactorLastStep = sqlb.Col[int64](TableFactor, ColLastStep) FactorFailures = sqlb.Col[int](TableFactor, ColFailures) FactorLockedUntil = sqlb.Col[*time.Time](TableFactor, ColLockedUntil) FactorCreatedAt = sqlb.Col[time.Time](TableFactor, ColCreatedAt) FactorMethod = sqlb.Col[string](TableFactor, ColMethod) FactorIssuer = sqlb.Col[string](TableFactor, ColIssuer) RecoveryID = sqlb.Col[string](TableRecovery, ColRecoveryID) RecoveryUser = sqlb.Col[string](TableRecovery, ColRecoveryUser) RecoveryHash = sqlb.Col[[]byte](TableRecovery, ColRecoveryHash) RecoveryUsed = sqlb.Col[*time.Time](TableRecovery, ColRecoveryUsed) // RecoverySealed is the sealed code under StoreEncrypted, "" otherwise. RecoverySealed = sqlb.Col[string](TableRecovery, ColRecoverySealed) // RecoverySeq is the code's place in its set; see ColRecoverySeq. RecoverySeq = sqlb.Col[int](TableRecovery, ColRecoverySeq) // Enabled is two_factor_enabled on auth_user, for a composite user. Enabled = sqlb.Col[bool](auth.TableUser, ColEnabled) )
Typed columns, for an app that reads the tables (reads are open; only the plugin writes them).
var ( ErrCodeInvalid = auth.NewError("twofactor.code_invalid", http.StatusBadRequest, "This code is not right.") ErrLocked = auth.NewError("twofactor.locked", http.StatusTooManyRequests, "Too many wrong codes. Wait and try again.") ErrAlreadyEnabled = auth.NewError("twofactor.already_enabled", http.StatusConflict, "Two-factor sign-in is already on.") // ErrSecretInvalid refuses ImportTOTP with a secret that is not base32 // or decodes to fewer than MinImportSecretBytes. ErrSecretInvalid = auth.NewError("twofactor.secret_invalid", http.StatusBadRequest, "The TOTP secret is not valid.") ErrNotEnrolled = auth.NewError("twofactor.not_enrolled", http.StatusConflict, "Start two-factor setup first.") // ErrMethodInvalid: Enroll named a method that is not one of MethodValues, // or one the plugin was not built with (MethodDelivered without // DeliveredCodes(), MethodTOTP with WithoutTOTP()). ErrMethodInvalid = auth.NewError("twofactor.method_invalid", http.StatusBadRequest, "Choose an available two-factor method.") // ErrNoChannel: MethodDelivered needs a verified email address or phone number // whose plugin can deliver, and the user has none. ErrNoChannel = auth.NewError("twofactor.no_channel", http.StatusConflict, "Verify an email address or phone number first.") // ErrNotCodeMethod: send was called for a user whose factor is an // authenticator app; there is nothing to send. ErrNotCodeMethod = auth.NewError("twofactor.not_code_method", http.StatusConflict, "Your authenticator app has the code.") ErrNoEncryptionKey = errors.New("twofactor: pass twofactor.WithEncryptionKey with a 32-byte key, or auth.WithSecret") // ErrNoMethod: WithoutTOTP() without DeliveredCodes() leaves no way to // enrol. ErrNoMethod = errors.New("twofactor: WithoutTOTP needs DeliveredCodes, or nobody can enrol") ErrBadCodeTTL = errors.New("twofactor: CodeTTL must be positive") ErrBadDigits = errors.New("twofactor: Digits must be 6 or 8") ErrBadPeriod = errors.New("twofactor: Period must be a positive number of whole seconds") ErrBadRecoveryLen = errors.New("twofactor: RecoveryCodeLength must be at least MinRecoveryCodeLength") ErrBadLockout = errors.New("twofactor: Lockout needs at least one allowed failure and a positive duration") // ErrBadRecoveryCodes is returned by confirm or regenerate when a // RecoveryCodeGenerator handed back no codes, an empty one or a duplicate. ErrBadRecoveryCodes = errors.New("twofactor: RecoveryCodeGenerator returned no codes, an empty code or a duplicate") // ErrIssuerInvalid refuses an EnrollRequest.Issuer longer than // MaxIssuerLength, or holding a colon (it separates the issuer from the // account in the otpauth label, so an app would split the name wrongly) // or a control character. ErrIssuerInvalid = auth.NewError("twofactor.issuer_invalid", http.StatusBadRequest, "The issuer name is not valid.") // ErrSecretUnreadable answers an operation whose stored secret will not // open: a TOTP secret on verify, check, disable, recovery-codes or // totp-uri, or a sealed recovery code on view-recovery-codes. The key it // was sealed with was dropped from the ring, the app secret changed // without keeping the previous one, or a Sealer's key service is down. // That is the server's fault, not the code's, so it is never counted as a // wrong code and never locks the factor; a recovery code still passes, // because it is checked by its hash, which the message says. The cause is // logged for the operator and stays wrapped for errors.Is // (auth.ErrSealedCorrupt, a KMS error); the client sees only this code. // Every operation that meets it runs for a caller who already proved the // first factor, so it tells nobody whether an account exists. ErrSecretUnreadable = auth.NewError("twofactor.secret_unreadable", http.StatusServiceUnavailable, "Two-factor codes cannot be checked right now. A recovery code still works; otherwise contact support.") )
Errors. The *auth.Error values are what a client sees; ErrNoEncryptionKey is a configuration mistake auth.New refuses at start-up.
var CredentialSignIns = []auth.EndpointID{"password.sign-in", "password.change-expired", "password.reauthenticate"}
CredentialSignIns are the operations after which DefaultPolicy asks for the factor: the sign-ins that prove a password. They are this library's equivalents of the routes better-auth's twoFactor plugin hooks (v1.7.7: /sign-in/email, /sign-in/username and /sign-in/phone-number, all password sign-ins; here one operation takes any identifier kind), plus the expired-password change, which signs in with a password too and would otherwise skip the factor, and the password re-authentication of a signed-in session (auth.Reauthenticate), which asks what a password sign-in asks, so a stolen session plus a leaked password cannot reach the fresh window without the code. The ids are strings because the password plugin is a module of its own; an integration test fails on an id no operation has.
var MethodValues = []Method{MethodTOTP, MethodDelivered, MethodSecurityKey}
MethodValues is the canonical iteration order over methods.
var RecoveryStorageValues = []RecoveryStorage{StoreHashed, StoreEncrypted}
RecoveryStorageValues is the canonical iteration order over storage modes.
Functions ¶
func AskEverySignIn ¶
func AskEverySignIn(_ context.Context, in PolicyRequest) (bool, error)
AskEverySignIn is the policy that asks for the factor after every sign-in, whatever the method (OAuth, SSO, magic link and one-time codes included), except from a trusted device: WithPolicy(AskEverySignIn) for an app whose second factor must not depend on how the user signed in. It was the default before DefaultPolicy followed better-auth.
func Bindings ¶
func Bindings(c *TwoFactorColumns) []sqlb.Binding
Bindings binds c's fields to their auth_user columns, for auth's LoadUserColumns or a hand-written select of the user.
func DefaultPolicy ¶
func DefaultPolicy(_ context.Context, in PolicyRequest) (bool, error)
DefaultPolicy is the policy without WithPolicy, better-auth's rule: ask for the factor after a password sign-in (CredentialSignIns), and after a sign-in that names no operation (a direct CreateSession from Go, which cannot say how the user proved who they are), except from a trusted device. It also asks after a sign-in whose method reports it proved one factor (SignInAttempt.SingleFactor): a passkey whose authenticator did not verify the user proves possession only. An OAuth, SSO, magic link, one-time-code or user-verified passkey sign-in does not ask: with those methods turned on, a user with a factor can sign in without it. AskEverySignIn asks after every method instead. A custom policy can call DefaultPolicy for the cases it does not decide.
Types ¶
type CodeRequest ¶
type CodeRequest struct {
Code string `json:"code"`
// TrustDevice, on verify, asks to skip the second factor on this device
// for the TrustDevices age. Refused (ErrTrustDisabled) when the plugin
// was built without TrustDevices, and with DisableSession
// (ErrTrustNeedsSession); ignored by the other operations.
TrustDevice bool `json:"trustDevice,omitempty"`
// DisableSession, on verify, checks the code and leaves the session
// pending: better-auth's disableSession. The code is spent and counted
// like any other; nobody is signed in. A signed-in user's step-up check
// is the check operation (CheckCode) instead.
DisableSession bool `json:"disableSession,omitempty"`
// Password is the user's password, on disable and recovery-codes, when
// the plugin was built with RequirePassword and the user has one;
// ignored otherwise.
Password auth.Token `json:"password,omitempty"`
// Method is the factor the user picked at the challenge: the code
// method Code is from, or MethodSecurityKey with SecurityKey set. ""
// means the user's first code factor, which is the only one a user
// enrolled before factors could be combined has. A method the user does
// not have is ErrFactorNotEnrolled. A recovery code passes with "" or
// any code method. On confirm it names the enrolment being confirmed.
Method Method `json:"method,omitempty"`
// SecurityKey is the assertion answering security-key-challenge, with
// Method MethodSecurityKey.
SecurityKey *SecurityKeyResponse `json:"securityKey,omitempty"`
}
CodeRequest carries a TOTP code or a recovery code. Spaces around it and dashes inside it are ignored, and case does not matter.
type DevicesResult ¶
type DevicesResult struct {
Devices []TrustedDevice `json:"devices"`
}
DevicesResult is the output of devices, oldest first.
type EnrollRequest ¶
type EnrollRequest struct {
Method Method `json:"method,omitempty"`
// Issuer names the app in this user's authenticator app instead of the
// plugin's Issuer: better-auth's per-call issuer, for one deployment
// serving several brands. It is stored with the factor, so totp-uri
// shows the same name later. At most MaxIssuerLength bytes, no colon, no
// control character (ErrIssuerInvalid); "" means the plugin's Issuer.
// Ignored for MethodDelivered, which has no authenticator app.
Issuer string `json:"issuer,omitempty"`
// Password is the user's password when the plugin was built with
// RequirePassword and the user has one; ignored otherwise.
Password auth.Token `json:"password,omitempty"`
// Proof is a current factor, needed to add a method beside factors that
// are on (ErrProofRequired without it). Ignored for a first factor.
Proof *FactorProof `json:"proof,omitempty"`
}
EnrollRequest chooses the method. Empty means MethodTOTP, so a client written before methods existed sends no body and gets what it always got.
type EnrollResult ¶
type EnrollResult struct {
Method Method `json:"method"`
Secret string `json:"secret,omitempty"`
URL string `json:"url,omitempty"`
// Enabled is true when SkipVerificationOnEnable turned the factor on at
// once; RecoveryCodes are then the user's codes, shown this once, and
// there is no confirm step.
Enabled bool `json:"enabled"`
RecoveryCodes []string `json:"recoveryCodes,omitempty"`
}
EnrollResult is what the app shows. For MethodTOTP: a QR code (URL) and the text to type (Secret). For MethodDelivered: Secret and URL are empty, and the first code is already on its way to the user's verified identity. Either way the factor stays off until Confirm proves the user receives codes.
type EnrolmentRule ¶
EnrolmentRule decides whether userID must have a second factor before their session is usable: always (func returning true), per role, or per organization, which the rule looks up itself through a so this plugin imports no organization model. An error requires enrolment: a rule that cannot decide fails closed.
type Factor ¶
type Factor struct {
Method Method `json:"method"`
// ID names a security key (remove-factor takes it); "" for a code factor.
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
// Primary marks the factor a code without a method is checked against.
Primary bool `json:"primary,omitempty"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
Factor is one of a user's confirmed second factors, as status lists them.
type FactorProof ¶
type FactorProof struct {
Method Method `json:"method,omitempty"`
Code string `json:"code,omitempty"`
SecurityKey *SecurityKeyResponse `json:"securityKey,omitempty"`
}
FactorProof is a current second factor of the user, asked for before a factor is added or removed while two-factor sign-in is on: a code (TOTP, delivered or recovery) or a security-key assertion. Method picks the factor the code is from; "" means the user's first code factor.
type ForgetDeviceRequest ¶
ForgetDeviceRequest names one device of the signed-in user, or all of them.
type LabelFunc ¶
LabelFunc names a user's account on their device. It receives the core, so it can read what the plugin cannot know, such as the user's email address through a.Identities, without the app capturing its *auth.Auth in a closure before auth.New has returned it. An empty result falls back to the default.
type Method ¶
type Method string
Method is how a user's second factor produces codes.
const ( // MethodTOTP: an authenticator app computes the codes from a shared // secret (RFC 6238). MethodTOTP Method = "totp" // MethodDelivered: the server mails or texts a one-time code to a verified // identity of the user at each sign-in. Weaker than TOTP (it trusts the // mailbox or the phone network), and the only second factor for users // who will not install an app. MethodDelivered Method = "delivered" )
The two methods. MethodTOTP is the default and what Enroll without a method means, so existing clients see no change. MethodDelivered exists only when the plugin was built with DeliveredCodes().
const MethodSecurityKey Method = "security_key"
MethodSecurityKey is a WebAuthn authenticator (a USB or NFC key, or the device's own platform authenticator) used as a second factor. It exists only with WithSecurityKeys. A security key is enrolled through security-key-register-begin and -finish, never through enroll, and is used at the challenge by sending a SecurityKeyResponse instead of a code.
type Option ¶
type Option func(*Plugin)
Option configures the plugin; pass options to New.
func AccountLabel ¶
AccountLabel sets the account name an authenticator app shows next to the issuer, such as the user's email address. The plugin does not depend on the email plugin, so it cannot pick one itself; without this option, or when fn returns "", the label is the user's name, or the user id when the name is empty.
func CodeTTL ¶
CodeTTL overrides DefaultCodeTTL, how long a delivered code works. The length of the code is the core's (auth.WithCodeLength).
func DeliveredCodes ¶
func DeliveredCodes() Option
DeliveredCodes lets users enrol MethodDelivered: a one-time code mailed or texted to a verified identity at each sign-in, through the identifier plugins' delivery (email.WithMailer, phone.WithSMS). Off by default, because a code in a mailbox is a weaker factor than an authenticator app; turn it on for users who will not install one. The sign-in step then has a send operation (EndpointSend) before verify.
func Issuer ¶
Issuer names the app in authenticator apps. It is written into the app at enrollment, so changing it later relabels nothing already enrolled.
func Lockout ¶
Lockout sets how many wrong codes in a row lock the factor and for how long; the defaults are MaxFailures and LockDuration. A six-digit code accepts 3 of a million values per step, so the lock is what makes guessing hopeless: raise maxFailures with care.
func ReadableTOTPURI ¶
func ReadableTOTPURI() Option
ReadableTOTPURI registers totp-uri: the signed-in user reads the otpauth:// URI of their authenticator-app factor again, to add a second device or redraw a lost QR code (better-auth's get-totp-uri). It needs a fresh sign-in and, for a user with a password, the password, which is better-auth's guard plus the fresh window.
Off by default, because the URI is the secret itself: whoever reads it clones the authenticator, and for a user without a password a stolen fresh session is then enough to defeat the second factor for good, with no trace the user would notice. Without this option a lost QR code during setup is covered by enrolling again.
func RecoveryCodeCount ¶
RecoveryCodeCount overrides DefaultRecoveryCodes. It applies at the next confirm or regenerate; codes already handed out keep working.
func RecoveryCodeGenerator ¶
RecoveryCodeGenerator replaces the random codes with the app's own, for a house format or words. fn returns the codes as the user will see them; they are compared after trimming, upper-casing and removing dashes and spaces, so a code is accepted however the user types it. Only their hashes are stored. Empty or duplicate codes fail the confirm with ErrBadRecoveryCodes.
func RecoveryCodeLength ¶
RecoveryCodeLength overrides DefaultRecoveryCodeLength, in base32 characters; at least MinRecoveryCodeLength. Codes are still shown in groups of four. Applies at the next confirm or regenerate.
func RecoveryCodeStorage ¶
func RecoveryCodeStorage(s RecoveryStorage) Option
RecoveryCodeStorage chooses how recovery codes are kept; the default is StoreHashed. StoreEncrypted registers view-recovery-codes and needs auth.WithSecret (ErrRecoveryStorageNeedsSecret). It applies to codes made from then on: switching to it leaves the current codes viewable only after the user makes a new set, and switching back leaves the sealed copies in place until the codes are used or replaced.
func RequireEnrolment ¶
func RequireEnrolment(rule EnrolmentRule) Option
RequireEnrolment makes a user the rule names, who has no second factor on, sign in to a session with the core's auth.TaskSetupMFA task pending: it reaches only status and the enrolment operations (enroll, confirm, and the security-key registration with WithSecurityKeys), and every other operation answers auth.ErrSessionTaskPending, until a factor is turned on. Then the session is replaced by an ordinary one under a new token, and the old token stops working. Without this option the plugin declares no task and nothing changes. A nil rule is refused (auth.ErrNilOption).
func RequirePassword ¶
func RequirePassword() Option
RequirePassword makes enroll, disable, recovery-codes and view-recovery-codes ask for the password of a user who has one, on top of the fresh sign-in they already need: better-auth's password field on enable, disable and generate-backup-codes. A user without a password (a passkey or social sign-in) passes on the fresh session alone, which is better-auth's allowPasswordless; better-auth's default, refusing such a user outright, is not offered, because it would keep exactly the users with no password from turning a second factor on. Off by default: the fresh session is the proof this library asks for everywhere else.
func SkipVerificationOnEnable ¶
func SkipVerificationOnEnable() Option
SkipVerificationOnEnable turns a factor on at enroll, without the first code confirm asks for: better-auth's skipVerificationOnEnable. Enroll then answers Enabled with the recovery codes (the only time they are shown), and signs out every other session as confirm does. A delivered-code factor sends no first code, like better-auth's method "otp"; the user still needs a verified identity (ErrNoChannel otherwise).
Off by default, and riskier: nothing proves the authenticator app holds the secret, so a user who mis-scanned the QR code is locked out at the next sign-in until they use a recovery code, and a delivered-code user whose mailbox or phone does not receive codes is locked out the same way. Turn it on only where the app shows the recovery codes prominently.
func TrustDevices ¶
TrustDevices lets a user skip the second factor on a device they trusted at verify (CodeRequest.TrustDevice), for maxAge from that moment; 0 means DefaultTrustedDeviceAge. Trust is bound to the user and to the device token the transport keeps in its own cookie, is listed and revocable (Devices, ForgetDevice), ends when the factor is disabled, and is cleared when the user's password changes, because a device trusted before a takeover should not outlive the response to it.
func WithDigits ¶
WithDigits sets the code length, 6 (Digits) or 8 (LongDigits). It is written into the authenticator app at enrolment, so changing it later breaks every enrolled user's app.
func WithEncryptionKey ¶
WithEncryptionKey sets the AES-256 key that seals TOTP secrets at rest. Required unless the app set auth.WithSecret, in which case the key is derived from it under auth.KeyLabelTwoFactor and a rotation of the secret keeps every enrolment opening. Losing the key disables every enrolled factor; users then sign in with a recovery code.
func WithPeriod ¶
WithPeriod sets the time step, in whole seconds; the default is Step. Like WithDigits it is written into the app at enrolment, and changing it later breaks enrolled apps.
func WithPolicy ¶
WithPolicy replaces DefaultPolicy, for an app whose rule depends on the sign-in: ask after every method (AskEverySignIn), always ask an admin even after OAuth or on a trusted device, or ask only from an unknown network. For example, better-auth's rule for everyone but admins, who are asked after every method:
twofactor.WithPolicy(func(ctx context.Context, in twofactor.PolicyRequest) (bool, error) {
if isAdmin(ctx, in.UserID) {
return twofactor.AskEverySignIn(ctx, in)
}
return twofactor.DefaultPolicy(ctx, in)
})
A nil policy keeps DefaultPolicy.
func WithSealer ¶
WithSealer seals TOTP secrets and StoreEncrypted recovery codes with s, for an app whose keys live in a KMS or HSM rather than in process memory. WithEncryptionKey still wins for TOTP secrets; without either the plugin uses auth.WithSealer's Sealer, then keys derived from auth.WithSecret. A nil s is the same as not passing the option. Values already stored stay readable only through a Sealer that opens them: switching from the derived keys to a KMS needs the old values re-enrolled or a Sealer that falls back to auth.RingSealer for them.
func WithSecurityKeys ¶
func WithSecurityKeys(s SecurityKeyStore) Option
WithSecurityKeys offers security keys as a second factor, stored by s (passkey's SecurityKeys; register the passkey plugin too). Methods and status then list MethodSecurityKey, and the security-key operations are registered. A nil s is refused (auth.ErrNilOption).
func WithoutTOTP ¶
func WithoutTOTP() Option
WithoutTOTP removes the authenticator-app method: Enroll with MethodTOTP is ErrMethodInvalid, and a plugin without DeliveredCodes is refused by auth.New (ErrNoMethod). Factors already enrolled with TOTP keep working.
type PasswordChecker ¶
type PasswordChecker interface {
auth.Plugin
HasPassword(ctx context.Context, userID string) (bool, error)
Verify(ctx context.Context, password auth.Token) error
}
PasswordChecker is what the plugin needs from a password plugin to ask for the user's password, as better-auth does: the two methods `*password.Plugin` already has. Found through auth.PluginOf at Init, so this package imports no other module and an app registers nothing extra. Verify runs the password plugin's own operation, with its rate limit and its enumeration-safe timing.
type PasswordRequest ¶
PasswordRequest carries the user's password, for totp-uri and view-recovery-codes; ignored for a user without one.
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin is the TOTP second factor. Build it with New and pass it to auth.WithPlugins; any sign-in method's sessions become pending for a user with a confirmed factor.
func New ¶
New builds the plugin. WithEncryptionKey is required; auth.New refuses the plugin without it.
func (*Plugin) CheckCode ¶
CheckCode verifies a current code or an unused recovery code for the signed-in user in ctx and changes no session: a step-up before a sensitive action. It is the check operation, so it has verify's rate rule and lockout; a delivered-code user calls Send first.
func (*Plugin) Columns ¶
func (p *Plugin) Columns() []auth.ColumnExtension
Columns adds two_factor_enabled to auth_user, default false, which is what SecondFactorRequired reads.
func (*Plugin) Confirm ¶
Confirm checks the first code and turns the factor on. It returns the recovery codes, the only time they are shown, and signs out every other session, since those were created without the factor.
func (*Plugin) CredentialChanged ¶
CredentialChanged forgets every trusted device of userID. The core calls it when a plugin reports that a credential of the user changed (a password reset or change): a device trusted before the response to a stolen account must not keep skipping the second factor after it.
func (*Plugin) Devices ¶
func (p *Plugin) Devices(ctx context.Context) ([]TrustedDevice, error)
Devices lists the signed-in user's trusted devices, oldest first.
func (*Plugin) DisableFor ¶
DisableFor pauses userID's second factor: sign-in stops asking for it, and the factor, its recovery codes and trusted devices stay, so EnableFor can switch it back on without the user enrolling again. It is the support desk's answer to "my phone is in for repair": Reset is the answer to "my phone is gone". While paused the user sees two-factor as off in status and may enrol afresh, which replaces the paused factor. Sessions are left alone, as with Reset. No confirmed factor is ErrNotEnrolled; pausing a paused factor changes nothing.
func (*Plugin) DisableWith ¶
func (p *Plugin) DisableWith(ctx context.Context, in CodeRequest) error
DisableWith is Disable with the full request, to pass the password RequirePassword asks for.
func (*Plugin) EnableFor ¶
EnableFor switches a paused factor back on: the next sign-in asks for it again. Every session of the user ends when it does, for the reason confirm gives: they were created without the factor. A factor that is already on is left as it is and its sessions are kept; no confirmed factor is ErrNotEnrolled, because there is nothing to ask for.
func (*Plugin) Enroll ¶
func (p *Plugin) Enroll(ctx context.Context) (EnrollResult, error)
Enroll starts setup for the signed-in user, replacing any unconfirmed enrollment. It fails with ErrAlreadyEnabled when a factor is on: turning it off needs a code, and a fresh enrollment would bypass that. With SkipVerificationOnEnable the factor is on when it returns.
func (*Plugin) EnrollWith ¶
func (p *Plugin) EnrollWith(ctx context.Context, in EnrollRequest) (EnrollResult, error)
EnrollWith is Enroll with a chosen method, issuer or password. MethodDelivered sends the first code at once; a method the plugin was not built with is ErrMethodInvalid.
func (*Plugin) FactorOf ¶
func (p *Plugin) FactorOf(ctx context.Context, userID string) (method string, enabled bool, err error)
FactorOf reports userID's second factor for a support screen: the method of the confirmed factor ("" when there is none, which includes an enrolment still waiting for its first code) and whether sign-in asks for it (two_factor_enabled). A confirmed factor with enabled false is one DisableFor paused. It is the read side of the admin plugin's two-factor routes, which find this plugin by method set (admin.TwoFactorManager), so it returns plain values and the admin plugin imports nothing from here. Like Reset it checks nobody: the caller authorizes.
func (*Plugin) ForgetDevice ¶
func (p *Plugin) ForgetDevice(ctx context.Context, in ForgetDeviceRequest) error
ForgetDevice revokes one trusted device by id, or every one with All. The next sign-in from it asks for the second factor again. When the device is the one this request came from, the transport is told to drop its token.
func (*Plugin) ImportTOTP ¶
ImportTOTP enrols userID with a TOTP secret from another system (its base32 form, as Supabase's auth.mfa_factors.secret or the otpauth URL's secret parameter), from Go only. The row is confirmed at once and two_factor_enabled is set, exactly as a confirmed Enroll leaves them, so the user's next sign-in asks for the code their existing app shows. No recovery codes are made (there is nobody to show them to; the user regenerates a set from settings). Refused: a secret that is not base32 or shorter than MinImportSecretBytes (ErrSecretInvalid), a user already enrolled (ErrAlreadyEnabled), an unknown user (auth.ErrUserNotFound), and a plugin built WithoutTOTP (ErrMethodInvalid). An unconfirmed enrolment in progress is replaced. It runs in one transaction, or joins the one in ctx.
func (*Plugin) Init ¶
Init captures the core, which the plugin's reads and writes go through, and finds the password plugin that checks the password totp-uri and RequirePassword ask for. Its absence is not an error: without passwords every user is passwordless, and the fresh session is the proof.
func (*Plugin) Methods ¶
Methods lists the methods a user may enrol with this configuration, in MethodValues order, for a settings page that offers the choice.
func (*Plugin) Name ¶
Name is PluginName, the plugin's permanent identity in the migration ledger and in column ownership.
func (*Plugin) Operations ¶
Operations lists enroll, confirm, disable, verify, check, recovery-codes, status and remove-factor; send when DeliveredCodes is on (without the method the route would only ever answer ErrNotCodeMethod); devices and forget-device when TrustDevices is; totp-uri with ReadableTOTPURI; view-recovery-codes with StoreEncrypted; the security-key challenge and registration with WithSecurityKeys.
func (*Plugin) OptionErr ¶
OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).
func (*Plugin) RateLimits ¶
RateLimits bounds every operation that takes a code or reads a secret back per IP (codeIPLimit a minute), and send (sendIPLimit per sendIPWindow) because every send costs a message. The lockout, not these rules, is what stops guessing; they stop a spray from one address from reaching the database at full speed. A rule naming an operation that is not registered is refused by auth.New, so totp-uri and view-recovery-codes get theirs only when they exist.
func (*Plugin) Reauthenticate ¶
func (p *Plugin) Reauthenticate(ctx context.Context, in ReauthenticateRequest) (auth.Reauthentication, error)
Reauthenticate finishes a re-authentication whose first step asked for the second factor: it checks code like verify (same lockout, recovery codes spent) and then spends ticket, after which the signed-in session is fresh again. A wrong code is ErrCodeInvalid; a wrong, spent or expired ticket, or one from another session, is auth.ErrReauthTicketInvalid.
func (*Plugin) RegenerateRecoveryCodes ¶
RegenerateRecoveryCodes replaces every recovery code of the signed-in user with a new set and returns it, the only time the new codes are shown. It needs a fresh session and a current app code or an unused recovery code, like disable: whoever asks for new codes must be able to pass the factor.
func (*Plugin) RegenerateRecoveryCodesWith ¶
func (p *Plugin) RegenerateRecoveryCodesWith(ctx context.Context, in CodeRequest) (RecoveryCodes, error)
RegenerateRecoveryCodesWith is RegenerateRecoveryCodes with the full request, to pass the password RequirePassword asks for.
func (*Plugin) RemoveFactor ¶
func (p *Plugin) RemoveFactor(ctx context.Context, in RemoveFactorRequest) (RemoveFactorResult, error)
RemoveFactor removes one factor of the signed-in user.
func (*Plugin) Reset ¶
Reset turns userID's second factor off for them: the support desk's answer to a user who lost their phone and their recovery codes. It deletes the factor (confirmed or still being set up), every further factor and security key, every recovery code and every trusted device, and clears two_factor_enabled, in one transaction, so the next sign-in asks for the first factor only and the user can enrol again.
It asks for no code and no password, so it is a server-side call for the app to put behind its own staff check; the plugin serves no route for it, because who may reset whom is the app's rule. Sessions are left alone: the user's own were created with the factor, and an app that suspects the account was taken calls auth's DeleteUserSessions as well. A user with no factor is ErrNotEnrolled, after the same cleanup, so a mistyped id is seen.
func (*Plugin) SecondFactorRequired ¶
SecondFactorRequired reports whether userID has a confirmed factor and the request is not from a device the user trusted (TrustDevices). It reads the plugin's own column on auth_user, so the usual check costs one indexed read; only an enabled user with a device token on the request costs the device lookup. With WithPolicy it asks the policy, with a sign-in that names no method; CreateSession calls SecondFactorRequiredForSignIn instead.
func (*Plugin) SecondFactorRequiredForSignIn ¶
func (p *Plugin) SecondFactorRequiredForSignIn(ctx context.Context, in auth.SignInAttempt) (bool, error)
SecondFactorRequiredForSignIn is SecondFactorRequired for one sign-in, decided by the Policy (DefaultPolicy unless WithPolicy) for a user with a confirmed factor.
func (*Plugin) SecurityKeyChallengeFor ¶
func (p *Plugin) SecurityKeyChallengeFor(ctx context.Context) (SecurityKeyChallenge, error)
SecurityKeyChallengeFor starts a security-key assertion for the user of the pending or signed-in session in ctx; the answer goes to verify, check, reauthenticate or a proof as CodeRequest.SecurityKey.
func (*Plugin) SecurityKeyRegisterBegin ¶
func (p *Plugin) SecurityKeyRegisterBegin(ctx context.Context, in SecurityKeyRegisterBeginRequest) (SecurityKeyChallenge, error)
SecurityKeyRegisterBegin starts adding a security key as a second factor.
func (*Plugin) SecurityKeyRegisterFinish ¶
func (p *Plugin) SecurityKeyRegisterFinish(ctx context.Context, in SecurityKeyRegisterFinishRequest) (SecurityKeyRegistered, error)
SecurityKeyRegisterFinish stores the key and, when it is the first factor, turns two-factor sign-in on.
func (*Plugin) Send ¶
Send mails or texts a one-time code to the user of the session in ctx, for a factor enrolled with MethodDelivered: at the sign-in step (a pending session) and before disable or regenerate on a signed-in one; the operation accepts either (auth.SignedInOrPending). A TOTP user is ErrNotCodeMethod. Each call replaces the open code.
func (*Plugin) SessionTasks ¶
func (p *Plugin) SessionTasks() []auth.SessionTask
SessionTasks declares auth.TaskSetupMFA with exactly the operations a user needs to enrol from nothing, only under RequireEnrolment.
func (*Plugin) SessionTasksFor ¶
func (p *Plugin) SessionTasksFor(ctx context.Context, in auth.SignInAttempt) ([]auth.SessionTaskID, error)
SessionTasksFor requires auth.TaskSetupMFA of a user the rule names who has no second factor on (none enrolled, or one DisableFor paused).
func (*Plugin) Status ¶
Status reports the second-factor state of the user in ctx: a pending sign-in asks it to learn which form to show, a settings page whether the factor is on. Nothing in it is secret: a pending session exists only after the first factor was proven, and a signed-in user may always see their own settings.
func (*Plugin) TOTPURI ¶
func (p *Plugin) TOTPURI(ctx context.Context, in PasswordRequest) (TOTPURIResult, error)
TOTPURI reads the signed-in user's authenticator URI back; see ReadableTOTPURI. Without that option the operation is not registered and the call fails.
func (*Plugin) Tables ¶
Tables declares the plugin's tables, so auth.New can refuse another plugin claiming the same name or alias.
func (*Plugin) TypedMigrations ¶
TypedMigrations creates the two tables for engine, and refuses an engine the core does not know.
func (*Plugin) Unlock ¶
Unlock lifts userID's lockout before it expires and clears the count of wrong codes, for a support desk that has confirmed who is asking. The factor, its secret and the recovery codes are untouched. Like Reset it is a server-side call the app authorizes; a user with no factor is ErrNotEnrolled.
func (*Plugin) UserTables ¶
UserTables declares that the factor, further-factor, recovery-code and trusted-device rows belong to the user in their user_id column, so DeleteUser removes them (no foreign key does, ADR-0002).
func (*Plugin) Validate ¶
Validate requires an encryption key of auth.SealKeySize bytes, or the app's secret to derive one from: without either no secret could be stored, and every enrollment would fail at run time.
func (*Plugin) Verify ¶
Verify passes the second factor for the pending session in ctx. VerifyWith with DisableSession checks the code without promoting the session; a signed-in step-up is CheckCode.
func (*Plugin) VerifyWith ¶
func (p *Plugin) VerifyWith(ctx context.Context, in CodeRequest) error
VerifyWith is Verify with the full request, to trust the device (CodeRequest.TrustDevice) after the code passes.
func (*Plugin) ViewRecoveryCodes ¶
func (p *Plugin) ViewRecoveryCodes(ctx context.Context, in PasswordRequest) (RecoveryCodes, error)
ViewRecoveryCodes shows the signed-in user's unused recovery codes again, as they were handed out, under StoreEncrypted (better-auth's view-backup-codes). It needs a fresh sign-in, and the password under RequirePassword. Without StoreEncrypted the operation is not registered and the call fails.
type Policy ¶
type Policy func(ctx context.Context, in PolicyRequest) (bool, error)
Policy decides whether a sign-in of a user who has a confirmed factor asks for it: true creates a pending session, false a full one. It is consulted only for such a user, because a factor cannot be asked of someone who has none; making a user enrol is the app's own rule, not a sign-in policy. An error requires the factor: a policy that cannot decide fails closed.
type PolicyRequest ¶
type PolicyRequest struct {
auth.SignInAttempt
DeviceTrusted bool
}
PolicyRequest is what a Policy decides on: the sign-in (the user, the client, the operation and sign-in method, such as "password", "oauth" or "sso") and whether the request carries a device the user trusted (TrustDevices; always false without it). A struct, so fields can be added without breaking a policy.
type ReauthenticateRequest ¶
type ReauthenticateRequest struct {
Code string `json:"code"`
Ticket auth.WireToken `json:"ticket"`
// Method and SecurityKey pick the factor, as on CodeRequest.
Method Method `json:"method,omitempty"`
SecurityKey *SecurityKeyResponse `json:"securityKey,omitempty"`
}
ReauthenticateRequest is the input of reauthenticate: a current code or a recovery code, and the ticket the first re-authentication step answered with (auth.Reauthentication.Ticket).
type RecoveryCodes ¶
type RecoveryCodes struct {
Codes []string `json:"codes"`
}
RecoveryCodes are shown at confirm or regenerate. Only their hashes are stored, unless RecoveryCodeStorage(StoreEncrypted) keeps a sealed copy that view-recovery-codes can show again.
type RecoveryStorage ¶
type RecoveryStorage string
RecoveryStorage is how recovery codes are kept.
const ( // StoreHashed keeps the SHA-256 hash only. The default: a leaked table // gives an attacker nothing to type. StoreHashed RecoveryStorage = "hashed" // StoreEncrypted also keeps each code sealed with AES-256-GCM under a key // derived from auth.WithSecret (KeyLabelRecoveryCodes), bound to the user // id. A leaked table alone still gives nothing; a leaked table AND the // secret give every unused code, which hashed storage never would. StoreEncrypted RecoveryStorage = "encrypted" )
The storage modes.
type RemoveFactorRequest ¶
type RemoveFactorRequest struct {
Method Method `json:"method"`
ID string `json:"id,omitempty"`
Proof FactorProof `json:"proof"`
Password auth.Token `json:"password,omitempty"`
}
RemoveFactorRequest names the factor to remove: a code method, or MethodSecurityKey with the key's ID. Proof is any current factor of the user (the one being removed included); Password is asked under RequirePassword.
type RemoveFactorResult ¶
type RemoveFactorResult struct {
Enabled bool `json:"enabled"`
}
RemoveFactorResult reports whether two-factor sign-in is still on: false after the last factor went, which also deleted the recovery codes.
type SecurityKey ¶
type SecurityKey struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
// AAGUID names the authenticator model when it disclosed one.
AAGUID string `json:"aaguid,omitempty"`
// Transports are how the key said it is reached ("usb", "nfc", …).
Transports []string `json:"transports"`
}
SecurityKey is one registered security key, as the factor list shows it.
type SecurityKeyChallenge ¶
type SecurityKeyChallenge struct {
Ceremony string `json:"ceremony"`
Options json.RawMessage `json:"options"`
}
SecurityKeyChallenge starts a WebAuthn ceremony: the client passes Options to navigator.credentials.create or .get and sends Ceremony back with the authenticator's answer. Ceremony is a single-use handle.
type SecurityKeyRegisterBeginRequest ¶
type SecurityKeyRegisterBeginRequest struct {
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
Password auth.Token `json:"password,omitempty"`
}
SecurityKeyRegisterBeginRequest is the input of security-key-register-begin: the authenticator kind (as passkey's), and the password when RequirePassword asks for it.
type SecurityKeyRegisterFinishRequest ¶
type SecurityKeyRegisterFinishRequest struct {
Ceremony string `json:"ceremony"`
Credential json.RawMessage `json:"credential"`
Name string `json:"name,omitempty"`
Proof *FactorProof `json:"proof,omitempty"`
}
SecurityKeyRegisterFinishRequest completes the registration. Proof is required when two-factor sign-in is already on (ErrProofRequired).
type SecurityKeyRegistered ¶
type SecurityKeyRegistered struct {
Key SecurityKey `json:"key"`
RecoveryCodes []string `json:"recoveryCodes"`
}
SecurityKeyRegistered is the new key. When it was the user's first factor, two-factor sign-in is now on and RecoveryCodes are the user's codes, shown this once; otherwise RecoveryCodes is empty and the codes the user holds stay valid.
type SecurityKeyRegistration ¶
type SecurityKeyRegistration struct {
Ceremony string `json:"ceremony"`
Credential json.RawMessage `json:"credential"`
Name string `json:"name,omitempty"`
}
SecurityKeyRegistration answers a registration ceremony; Name labels the key in the factor list.
type SecurityKeyResponse ¶
type SecurityKeyResponse struct {
Ceremony string `json:"ceremony"`
Credential json.RawMessage `json:"credential"`
}
SecurityKeyResponse answers a security-key assertion: the ceremony from the challenge and the browser's PublicKeyCredential JSON.
type SecurityKeyStore ¶
type SecurityKeyStore interface {
// BeginSecurityKeyRegistration starts registering a key for u;
// attachment is "platform", "cross-platform" or "" for either.
BeginSecurityKeyRegistration(ctx context.Context, u auth.User, attachment string) (SecurityKeyChallenge, error)
// FinishSecurityKeyRegistration verifies the attestation of a ceremony
// begun for userID and stores the key.
FinishSecurityKeyRegistration(ctx context.Context, userID string, in SecurityKeyRegistration) (SecurityKey, error)
// BeginSecurityKeyAssertion starts an assertion naming userID's keys.
BeginSecurityKeyAssertion(ctx context.Context, userID string) (SecurityKeyChallenge, error)
// FinishSecurityKeyAssertion is nil only for a valid assertion, by one
// of userID's keys, of a ceremony begun for userID and not used before.
// Every refusal is ErrSecurityKeyInvalid; any other error is a failure
// of the server and is not counted against the user.
FinishSecurityKeyAssertion(ctx context.Context, userID string, in SecurityKeyResponse) error
// SecurityKeys lists userID's keys, oldest first; never nil.
SecurityKeys(ctx context.Context, userID string) ([]SecurityKey, error)
// RemoveSecurityKey deletes one of userID's keys; an id that is not
// theirs is ErrFactorNotFound.
RemoveSecurityKey(ctx context.Context, userID, id string) error
// RemoveSecurityKeys deletes every key of userID.
RemoveSecurityKeys(ctx context.Context, userID string) error
}
SecurityKeyStore is what twofactor needs from a WebAuthn plugin to offer security keys as a second factor; passkey.(*Plugin).SecurityKeys returns one. The plugin owns the protocol and the storage; twofactor owns the rules (which factors a user has, the lockout, recovery codes, promotion of the session). Every method is for one user, and must refuse a key or a ceremony of another user.
type Status ¶
type Status struct {
Enabled bool `json:"enabled"`
Method Method `json:"method,omitempty"`
// Methods are the methods a user may enrol with this configuration, in
// MethodValues order (the Methods function).
Methods []Method `json:"methods"`
// TrustDevices is true when the plugin was built with TrustDevices, so
// the verify form may offer "remember this device".
TrustDevices bool `json:"trustDevices"`
// Factors are the user's confirmed factors when Enabled, the primary
// code factor first, then further code factors, then security keys: a
// challenge page offers one of them. Empty while off.
Factors []Factor `json:"factors"`
}
Status is the output of status. Enabled is false until confirm, so a factor enrolled but never confirmed does not count. Method is set only when Enabled. Methods and TrustDevices describe the configuration, so one call tells a page what it may offer: the methods a user may enrol, and whether verify accepts CodeRequest.TrustDevice.
type TOTPURIResult ¶
type TOTPURIResult struct {
URL string `json:"totpURI"`
}
TOTPURIResult is the otpauth:// URI an authenticator app reads, with the issuer the user enrolled with. The field is named as better-auth names it.
type TrustedDevice ¶
type TrustedDevice struct {
ID string `json:"id"`
UserAgent string `json:"userAgent"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
LastUsed *time.Time `json:"lastUsedAt"`
// Current is true for the device this request came from.
Current bool `json:"current"`
}
TrustedDevice is one row of the device list: what a settings page shows.
type TwoFactorColumns ¶
type TwoFactorColumns struct {
TwoFactorEnabled bool
}
TwoFactorColumns is this plugin's flat part of a composite user: embed it in the app's user struct and load it with Bindings, so "is two-factor on" comes with the user row and costs no join.