Documentation
¶
Overview ¶
Package sulis is a Go authentication library for consumer-owned persistence: password login, magic-link login, two-factor pending-login tokens, password reset, email verification, server-side sessions, and the HTTP middleware that attaches an authenticated user and session to a request context. The totp, passkey, and recovery subpackages add TOTP, WebAuthn passkeys, and recovery codes as second factors or standalone credentials; passwordcheck screens new passwords against known-compromised values.
Store-interface architecture ¶
sulis ships no database driver and stores nothing itself. Every piece of state it needs — users, sessions, and tokens for the root package; TOTP credentials, passkey credentials and their WebAuthn challenges, and recovery codes for the respective subpackages — is read and written through a small interface (UserStore, SessionStore, TokenStore, and each subpackage's own Store) that the consumer implements against whatever they already run: Postgres, SQLite, DynamoDB, or anything else. Those interfaces document requirements no compiler can check — ConsumeToken must find-and-mark a token used in one atomic step, UpdateUser must reject a write built from a stale read, DeleteSession must scope its delete to the owning user — because a store that gets one of them wrong satisfies the interface and still breaks the guarantee the library is built on. See "Store contracts" below for how to prove an implementation correct instead of hoping it is.
Safe by default ¶
Every default is chosen so that calling New and nothing else is already the secure configuration, not a starting point that still needs hardening: an in-process rate limiter guards password, reset, and magic-link attempts before any other option is set; new passwords are screened against a breach corpus; a new session is refused for an account whose email isn't verified yet, including the rotation RefreshSession would otherwise mint from a signup session; a WebAuthn passkey requires user verification (a PIN or a biometric), not bare possession of an unlocked device; cookie sessions carry HttpOnly, Secure, SameSite=Lax, and a __Host- name; and changing a password revokes every other session on the account. Every one of these can be turned off — WithoutRateLimiting, WithPasswordChecker(nil), WithRequireVerifiedEmail(false), passkey.WithUserVerification with protocol.VerificationDiscouraged, WithRevokeSessionsOnPasswordChange(false) — but each is a visible call a reviewer can find, never the silent consequence of forgetting one. See the README's "Operational requirements" section for the full list and the reasoning behind each default.
A minimal end-to-end flow ¶
Registration and login against the reference in-memory stores (package memstore — fine for this, tests, and local development; never production):
users, sessions, tokens := memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore()
auth, err := sulis.New(users, sessions, tokens, sulis.NoSecondFactors{})
if err != nil {
log.Fatal(err)
}
ri := sulis.RequestInfo{IP: r.RemoteAddr}
user, session, rawToken, err := auth.Register(ctx, email, password, ri)
if err != nil {
return err // ErrUserAlreadyExists, ErrInvalidEmail,
// ErrPasswordTooShort/TooLong, ErrPasswordCompromised, ...
}
setSessionCookie(auth.SessionCookie(rawToken, session.ExpiresAt))
// Later, on a login request:
result, err := auth.Login(ctx, email, password, ri)
if err != nil {
return err // ErrInvalidCredentials, ErrRateLimited, ErrEmailNotVerified, ...
}
if result.NeedsSecondFactor {
// No session exists yet — see CompleteTwoFactor and the totp,
// passkey, and recovery subpackages.
return promptForSecondFactor(result.User, result.PendingToken)
}
setSessionCookie(auth.SessionCookie(result.SessionToken, result.Session.ExpiresAt))
A NoSecondFactors application still gets rate limiting, password screening, email-verification gating, and hashed everything for free; a real SecondFactorChecker implementation (backed by totp.Store, passkey.Store, or both) is what turns the NeedsSecondFactor branch above from dead code into two-factor authentication. See package example tests for compiler-checked walkthroughs of password login with a second factor, magic links, passkeys, password reset, and email change.
Store contracts ¶
Every store interface's doc comment states its atomicity, scoping, and error-sentinel requirements; the README's "Store Contracts" section collects them with reference SQL. Package storetest turns those contracts into an executable conformance suite — supported public API and the intended integration path, not an internal test helper:
func TestMyUserStore(t *testing.T) {
storetest.RunUserStore(t, func() sulis.UserStore { return newMyUserStore(t) })
}
Package memstore is a reference implementation of every interface in this module (root and subpackages), written to be read end to end and proven, by that same suite, to satisfy every contract it documents.
Security events ¶
EventKind's constants (events.go) are a closed, dot-namespaced taxonomy of this root package's own security-relevant decisions — a password refused, a second factor demanded, a session issued or expired, a limiter tripped, an account disabled, and more. WithEventSink wires a sink through; NewSlogSink adapts a *slog.Logger in one line. See Event's doc comment for what a reported event may and may not contain.
The totp and passkey subpackages have no event sink of their own; wiring one through them is a separate piece of work (see the T509 Decisions row in PROGRESS.md). recovery does: its own independent EventKind, Event, EventSink, and WithEventSink (recovery/events.go), deliberately not wire-compatible with the root taxonomy — see recovery.EventSink's doc comment for why. An application wanting one unified event stream writes a small adapter translating a recovery.Event into whatever shape its own sink expects.
Where to go next ¶
The README documents every flow (password reset, magic link, two-factor, email verification, step-up re-authentication, cookie sessions and CSRF, security events) at the depth a doc comment can't. SECURITY.md covers how to report a vulnerability and the supported-version policy; docs/threat-model.md names the in-scope threats, the shipped mitigation for each, what's explicitly out of scope, and the residual risks — such as the default rate limiter being per-process rather than shared across instances — that remain the deploying application's to manage.
Example (EmailChange) ¶
Example_emailChange shows staging and confirming an email change, and the notification obligation that falls on the caller rather than on sulis.
package main
import (
"context"
"fmt"
"github.com/borfast/sulis"
"github.com/borfast/sulis/memstore"
)
func main() {
ctx := context.Background()
auth, err := sulis.New(
memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
sulis.NoSecondFactors{},
)
if err != nil {
fmt.Println("setup:", err)
return
}
ri := sulis.RequestInfo{IP: "203.0.113.13"}
user, _, sessionToken, err := auth.Register(ctx, "old-address@example.com", "correct-battery-staple", ri)
if err != nil {
fmt.Println("register:", err)
return
}
// sulis does not send mail. The raw token below is for delivery to the
// NEW address, to prove control of it.
//
// SECURITY (notify the OLD address): the caller MUST ALSO notify the
// OLD address that a change was requested — unconditionally, and with
// no token attached, since it needs no confirmation. That notification
// is how the account's rightful owner catches and can still undo a
// takeover attempt (an attacker who set this in motion controls the new
// address, never the old one) while the pending change hasn't taken
// effect yet. Skipping it turns a recoverable takeover attempt into a
// silent, completed one.
token, err := auth.ChangeEmail(ctx, user.ID, "new-address@example.com")
if err != nil {
fmt.Println("change:", err)
return
}
updated, err := auth.ConfirmEmailChange(ctx, token)
if err != nil {
fmt.Println("confirm:", err)
return
}
// Confirming an email change revokes every session on the account: the
// identity a still-live session was issued against has just changed.
_, _, err = auth.ValidateSession(ctx, sessionToken)
fmt.Println("live email:", updated.Email)
fmt.Println("old session still valid:", err == nil)
}
Output: live email: new-address@example.com old session still valid: false
Example (MagicLink) ¶
Example_magicLink shows requesting and redeeming a magic link for an address with no account yet: the account is created at redemption, and the mailbox proof it represents is treated as a full first factor.
package main
import (
"context"
"fmt"
"github.com/borfast/sulis"
"github.com/borfast/sulis/memstore"
)
func main() {
ctx := context.Background()
auth, err := sulis.New(
memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
sulis.NoSecondFactors{},
)
if err != nil {
fmt.Println("setup:", err)
return
}
// SECURITY (RequestInfo threading): the SAME RequestInfo is passed to
// both calls below. It feeds the IP dimension of rate limiting for this
// address — shared between creating and redeeming a link, so an
// attacker can't dodge the budget by splitting the two requests across
// different reported callers — and, on success, is copied onto the
// minted Session's IP/UserAgent fields for the user's own "where you're
// signed in" list. Pass the request actually in front of you each time,
// not a zero value, if you want either of those to mean anything.
ri := sulis.RequestInfo{IP: "203.0.113.15", UserAgent: "example-agent/1.0"}
// No account exists for this address yet. CreateMagicLinkToken issues a
// token without creating one; the user is created lazily at redemption.
token, bindingNonce, err := auth.CreateMagicLinkToken(ctx, "sam@example.com", ri)
if err != nil {
fmt.Println("create:", err)
return
}
// bindingNonce (non-empty by default) belongs in a short-lived,
// HttpOnly cookie set on THIS response — never embedded in the emailed
// link itself — and is read back from that cookie at redemption, so a
// copy of the link forwarded to someone else arrives without it.
// Passing it straight through here stands in for that round trip.
result, err := auth.RedeemMagicLink(ctx, token, bindingNonce, ri)
if err != nil {
fmt.Println("redeem:", err)
return
}
// No second factor is configured in this example, so a session exists
// immediately. An account with one enrolled would get NeedsSecondFactor
// set instead, exactly like Login — see the password + 2FA example.
fmt.Println("session issued:", result.Session != nil)
fmt.Println("session ip:", result.Session.IP)
}
Output: session issued: true session ip: 203.0.113.15
Example (Passkey) ¶
Example_passkey shows the shape of a passkey registration and a passkey login: starting a ceremony, and what happens once its browser-signed response comes back. Finishing either ceremony needs a real, signed WebAuthn response from a browser and an authenticator, which this process-local example has no way to produce, so it stops at that boundary — the calls that would follow are named in comments instead of faked.
package main
import (
"context"
"fmt"
"time"
"github.com/borfast/sulis"
"github.com/borfast/sulis/memstore"
"github.com/borfast/sulis/passkey"
)
func main() {
ctx := context.Background()
credentials := memstore.NewPasskeyStore()
challenges := memstore.NewChallengeStore()
svc, err := passkey.NewService(credentials, challenges, passkey.WebAuthnConfig{
RPDisplayName: "Example App",
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
})
if err != nil {
fmt.Println("setup:", err)
return
}
auth, err := sulis.New(
memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
sulis.NoSecondFactors{},
)
if err != nil {
fmt.Println("setup:", err)
return
}
ri := sulis.RequestInfo{IP: "203.0.113.9"}
user, _, _, err := auth.Register(ctx, "morgan@example.com", "correct-battery-staple", ri)
if err != nil {
fmt.Println("register:", err)
return
}
// RequireVerifiedEmail defaults to true, so IssueSessionUnchecked below
// would otherwise refuse with ErrEmailNotVerified. See the equivalent
// step in the password + 2FA example for why.
evToken, err := auth.CreateEmailVerificationToken(ctx, user.ID)
if err != nil {
fmt.Println("create verification token:", err)
return
}
if _, err := auth.VerifyEmail(ctx, evToken); err != nil {
fmt.Println("verify email:", err)
return
}
pu := &passkey.User{ID: []byte(user.ID), Name: user.Email, DisplayName: user.Email}
// Registration: BeginRegistration hands the browser a challenge for its
// authenticator to sign.
if _, err := svc.BeginRegistration(ctx, pu); err != nil {
fmt.Println("begin registration:", err)
return
}
// The browser's navigator.credentials.create() response is later
// handed, as raw bytes, to svc.FinishRegistrationResponse(ctx, pu,
// body) — or FinishRegistration(ctx, pu, r) for an *http.Request — which
// verifies the signature and saves the resulting *passkey.Credential.
// A fixture stands in below for what that call would have stored, so
// BeginLogin below has a credential to challenge.
if err := credentials.SaveCredential(ctx, &passkey.Credential{
ID: "example-credential",
UserID: user.ID,
CredentialID: []byte("example-credential-id"),
PublicKey: []byte("example-public-key"),
CreatedAt: time.Now(),
}); err != nil {
fmt.Println("seed credential:", err)
return
}
// Login: BeginLogin hands the browser a challenge for whichever
// registered credential it holds to sign.
if _, _, err := svc.BeginLogin(ctx, pu); err != nil {
fmt.Println("begin login:", err)
return
}
// FinishLoginResponse(ctx, pu, ceremonyID, body) would verify the signed
// assertion and return the Credential that produced it — rejecting a
// sign-count anomaly with passkey.ErrCloneWarning, a signal of possible
// cloning rather than a routine failure. sulis itself never checks a
// WebAuthn signature; that verification is entirely the passkey
// package's job. Once it succeeds, the caller — not sulis — is the one
// asserting the factor passed:
//
// SECURITY (IssueSessionUnchecked's vouching semantics): this method
// performs no credential check of its own. Calling it means THIS CODE
// is vouching that userID just completed every factor the application
// requires — here, a verified passkey assertion — not that sulis
// independently confirmed it. Never call it on the strength of a bare
// client claim.
session, _, err := auth.IssueSessionUnchecked(ctx, user.ID, sulis.AuthMethodPasskey)
if err != nil {
fmt.Println("issue session:", err)
return
}
fmt.Println("session method:", session.Method)
}
Output: session method: passkey
Example (PasswordLoginWithTwoFactor) ¶
Example_passwordLoginWithTwoFactor shows a password login for an account enrolled in TOTP: the password is only the first factor, and no session exists until the second factor is verified too.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/borfast/sulis"
"github.com/borfast/sulis/memstore"
"github.com/borfast/sulis/totp"
)
// totpSecondFactor adapts a totp.Store to sulis.SecondFactorChecker: a user
// has a second factor exactly when they have an active (verified) TOTP
// credential. A real application wires the equivalent against a
// passkey.Store, or both, and answers false only when neither is enrolled.
type totpSecondFactor struct{ store totp.Store }
func (c totpSecondFactor) HasSecondFactor(ctx context.Context, userID string) (bool, error) {
_, err := c.store.GetActiveTOTP(ctx, userID)
switch {
case err == nil:
return true, nil
case errors.Is(err, totp.ErrTOTPNotEnrolled):
return false, nil
default:
return false, err
}
}
func main() {
ctx := context.Background()
totpStore := memstore.NewTOTPStore()
auth, err := sulis.New(
memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
totpSecondFactor{store: totpStore},
)
if err != nil {
fmt.Println("setup:", err)
return
}
totpSvc, err := totp.NewService(totpStore, "ExampleApp")
if err != nil {
fmt.Println("setup:", err)
return
}
ri := sulis.RequestInfo{IP: "203.0.113.5"}
user, _, _, err := auth.Register(ctx, "kai@example.com", "correct-battery-staple", ri)
if err != nil {
fmt.Println("register:", err)
return
}
// RequireVerifiedEmail defaults to true, so Login below would otherwise
// refuse with ErrEmailNotVerified: Register's own signup session is
// exempt, but a later Login is not. A real application verifies email
// out of band (VerifyEmail, or a redeemed magic link); this stands in
// for that having already happened.
evToken, err := auth.CreateEmailVerificationToken(ctx, user.ID)
if err != nil {
fmt.Println("create verification token:", err)
return
}
if _, err := auth.VerifyEmail(ctx, evToken); err != nil {
fmt.Println("verify email:", err)
return
}
// Seed an already-enrolled, already-confirmed TOTP credential directly
// through the store, bypassing Service.Enroll/ConfirmEnrollment's code
// exchange. That's fixture setup, not the flow this example
// demonstrates: this whole example runs in well under a second, so a
// code accepted at enrollment would still be the current time step's
// code moments later at login, and Validate's replay check below would
// correctly refuse it as a genuine reuse. Recording LastUsedCounter as
// 0 here keeps that fixture out of the real check's way.
secret, _, err := totpSvc.Enroll(ctx, user.ID, user.Email)
if err != nil {
fmt.Println("enroll:", err)
return
}
pending, err := totpStore.GetPendingTOTP(ctx, user.ID)
if err != nil {
fmt.Println("pending:", err)
return
}
if _, err := totpStore.ConfirmEnrollment(ctx, user.ID, pending.ID, 0); err != nil {
fmt.Println("confirm:", err)
return
}
// The password is the FIRST factor only.
result, err := auth.Login(ctx, "kai@example.com", "correct-battery-staple", ri)
if err != nil {
fmt.Println("login:", err)
return
}
// SECURITY: branch on NeedsSecondFactor. A non-nil *LoginResult is not
// proof of a session by itself — treating it as "logged in" here would
// defeat two-factor authentication entirely.
if !result.NeedsSecondFactor {
fmt.Println("expected a pending second factor")
return
}
// The application collects a code from the user's authenticator app and
// verifies it independently — sulis never sees the TOTP secret, and has
// no way to check this itself.
code, err := totpSvc.Generate(secret, time.Now())
if err != nil {
fmt.Println("generate:", err)
return
}
if err := totpSvc.Validate(ctx, user.ID, code); err != nil {
// totp.ErrTOTPInvalid, totp.ErrTOTPNotEnrolled, totp.ErrTOTPNotVerified,
// totp.ErrTOTPReplayed, or totp.ErrTOTPRateLimited.
fmt.Println("validate:", err)
return
}
// Only now, with both factors verified, does a session exist.
final, err := auth.CompleteTwoFactor(ctx, user.ID, result.PendingToken, ri)
if err != nil {
fmt.Println("complete:", err)
return
}
fmt.Println("needs second factor:", result.NeedsSecondFactor)
fmt.Println("session issued:", final.Session != nil)
}
Output: needs second factor: true session issued: true
Example (PasswordReset) ¶
Example_passwordReset shows requesting and redeeming a password-reset token, including the response an unregistered address gets.
package main
import (
"context"
"fmt"
"github.com/borfast/sulis"
"github.com/borfast/sulis/memstore"
)
func main() {
ctx := context.Background()
auth, err := sulis.New(
memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
sulis.NoSecondFactors{},
)
if err != nil {
fmt.Println("setup:", err)
return
}
ri := sulis.RequestInfo{IP: "203.0.113.11"}
if _, _, _, err := auth.Register(ctx, "priya@example.com", "correct-battery-staple", ri); err != nil {
fmt.Println("register:", err)
return
}
// SECURITY (empty-token-means-unknown-email): an unregistered address
// gets back ("", nil) — the same shape a real issuance takes from the
// caller's side, and NOT distinguishable from it by error, return
// value, or (CreatePasswordResetToken does the same generate-and-discard
// work either way) the time it takes. A handler must render this
// exactly like the known-address case ("if that address is registered,
// we've sent a link") rather than branching on it — branching here is
// exactly how a "forgot password" form turns into an account-existence
// oracle.
token, err := auth.CreatePasswordResetToken(ctx, "nobody@example.com", ri)
if err != nil {
fmt.Println("unexpected error:", err)
return
}
fmt.Println("token for unknown address is empty:", token == "")
token, err = auth.CreatePasswordResetToken(ctx, "priya@example.com", ri)
if err != nil {
fmt.Println("create token:", err)
return
}
if err := auth.ResetPassword(ctx, token, "another-battery-staple"); err != nil {
fmt.Println("reset:", err)
return
}
_, err = auth.VerifyPassword(ctx, "priya@example.com", "another-battery-staple", ri)
fmt.Println("new password verifies:", err == nil)
}
Output: token for unknown address is empty: true new password verifies: true
Index ¶
- Constants
- Variables
- func IssueCSRFToken() (token string, cookie *http.Cookie, err error)
- func RequireCSRFToken(next http.Handler) http.Handler
- func RequireSameOrigin(allowed []string) func(http.Handler) http.Handler
- func VerifyCSRFToken(r *http.Request) error
- type Argon2Params
- type AuthMethod
- type Authentication
- type Budget
- type Config
- type Event
- type EventKind
- type EventSink
- type Limiter
- type LoginResult
- type MemoryLimiter
- type MemoryLimiterOption
- type MetadataKey
- type NoSecondFactors
- type Option
- func WithArgon2Params(p Argon2Params) Option
- func WithCookieName(name string) Option
- func WithEmailVerificationTokenDuration(d time.Duration) Option
- func WithEventSink(sink EventSink) Option
- func WithFailureLockout(threshold int, baseBackoff, maxBackoff time.Duration) Option
- func WithIdleTimeout(d time.Duration) Option
- func WithLimiter(l Limiter) Option
- func WithMagicLinkBinding(b bool) Option
- func WithMagicLinkDuration(d time.Duration) Option
- func WithPasswordChecker(c PasswordChecker) Option
- func WithPasswordLengthLimits(minLength, maxLength int) Option
- func WithPepper(pepper []byte) Option
- func WithRequireVerifiedEmail(require bool) Option
- func WithRevokeSessionsOnPasswordChange(revoke bool) Option
- func WithSessionDuration(d time.Duration) Option
- func WithTokenDuration(d time.Duration) Option
- func WithTokenSource(ts TokenSource) Option
- func WithTwoFactorTokenDuration(d time.Duration) Option
- func WithoutRateLimiting() Option
- type PasswordChecker
- type RequestInfo
- type SecondFactorChecker
- type Session
- type SessionStore
- type Sulis
- func (s *Sulis) Authenticate(next http.Handler) http.Handler
- func (s *Sulis) ChangeEmail(ctx context.Context, userID, newEmail string) (string, error)
- func (s *Sulis) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string, ri RequestInfo) error
- func (s *Sulis) ClearSessionCookie() *http.Cookie
- func (s *Sulis) CompleteTwoFactor(ctx context.Context, userID, rawToken string, ri RequestInfo) (*LoginResult, error)
- func (s *Sulis) ConfirmEmailChange(ctx context.Context, rawToken string) (*User, error)
- func (s *Sulis) CreateEmailVerificationToken(ctx context.Context, userID string) (string, error)
- func (s *Sulis) CreateMagicLinkToken(ctx context.Context, email string, ri RequestInfo) (token, bindingNonce string, err error)
- func (s *Sulis) CreatePasswordResetToken(ctx context.Context, email string, ri RequestInfo) (string, error)
- func (s *Sulis) CreatePasswordResetTokenStrict(ctx context.Context, email string, ri RequestInfo) (string, error)
- func (s *Sulis) CreateTwoFactorToken(ctx context.Context, userID string) (string, error)
- func (s *Sulis) DisableUser(ctx context.Context, userID, reason string) error
- func (s *Sulis) EnableUser(ctx context.Context, userID string) error
- func (s *Sulis) IssueSession(ctx context.Context, auth Authentication) (*Session, string, error)
- func (s *Sulis) IssueSessionUnchecked(ctx context.Context, userID string, method AuthMethod) (*Session, string, error)
- func (s *Sulis) ListUserSessions(ctx context.Context, userID string) ([]Session, error)
- func (s *Sulis) Login(ctx context.Context, email, password string, ri RequestInfo) (*LoginResult, error)
- func (s *Sulis) ReAuthenticate(ctx context.Context, session *Session, password string, ri RequestInfo) error
- func (s *Sulis) RedeemMagicLink(ctx context.Context, rawToken, bindingNonce string, ri RequestInfo) (*LoginResult, error)
- func (s *Sulis) RefreshSession(ctx context.Context, session *Session) (*Session, string, error)
- func (s *Sulis) Register(ctx context.Context, email, password string, ri RequestInfo) (*User, *Session, string, error)
- func (s *Sulis) RequireCSRFToken(next http.Handler) http.Handler
- func (s *Sulis) RequireRecentAuth(ctx context.Context, session *Session, maxAge time.Duration) error
- func (s *Sulis) RequireSameOrigin(allowed []string) func(http.Handler) http.Handler
- func (s *Sulis) ResetPassword(ctx context.Context, rawToken, newPassword string) error
- func (s *Sulis) RevokeAllSessions(ctx context.Context, userID string) error
- func (s *Sulis) RevokeSession(ctx context.Context, userID, sessionID string) error
- func (s *Sulis) SessionCookie(rawToken string, expires time.Time) *http.Cookie
- func (s *Sulis) SetInitialPassword(ctx context.Context, userID, newPassword string) error
- func (s *Sulis) ValidateSession(ctx context.Context, token string) (*Session, *User, error)
- func (s *Sulis) VerifyEmail(ctx context.Context, rawToken string) (*User, error)
- func (s *Sulis) VerifyPassword(ctx context.Context, email, password string, ri RequestInfo) (*User, error)
- type Token
- type TokenPurpose
- type TokenSource
- type TokenStore
- type User
- type UserStore
Examples ¶
Constants ¶
const ( // CSRFCookieName is the cookie IssueCSRFToken sets and // RequireCSRFToken/VerifyCSRFToken read the expected value from. // Unlike the session cookie it is intentionally NOT HttpOnly: a // same-origin script must be able to read it, to mirror it into // CSRFHeaderName on the requests it makes — that same-origin-only // readability, enforced by the browser regardless of this cookie's own // attributes, is the property the whole pattern rests on. CSRFCookieName = "__Host-csrf_token" // CSRFHeaderName is the request header VerifyCSRFToken checks first // for the client's echoed-back copy of the token. CSRFHeaderName = "X-CSRF-Token" // #nosec G101 -- a header name, not a credential // CSRFFormField is the fallback form field VerifyCSRFToken checks when // CSRFHeaderName is absent, for a traditional <form> POST that can't // set a custom header — render it as a hidden input alongside the // form. CSRFFormField = "csrf_token" // #nosec G101 -- a field name, not a credential )
Double-submit CSRF defense.
This is meaningful only for cookie-authenticated requests: a Bearer token is never attached to a request by the browser on its own, so a forged cross-site request has nothing to ride along with in the first place. A deployment configured with WithTokenSource(TokenSourceBearerOnly) — one that never calls SessionCookie either — needs none of this; see the README's "Cookie sessions and CSRF" section.
This is a PURE double-submit: CSRFCookieName's value is a bare random token, not cryptographically bound to the session that requested it (no HMAC over a session ID, no server-side lookup). By itself that means anyone who can get their own chosen value written into that cookie for this origin could echo the very same value back in CSRFHeaderName/ CSRFFormField themselves, defeating the check — the classical weakness of a bare double-submit token versus a session-bound one. This package closes that gap by layering, not by binding the token: CSRFCookieName carries the __Host- prefix, so neither a sibling subdomain nor a network attacker without HTTPS can set it for this origin in the first place, and RequireSameOrigin adds an independent, Fetch-Metadata-based check that doesn't depend on cookie contents at all. Treat IssueCSRFToken/RequireCSRFToken/VerifyCSRFToken as one layer of a defense meant to be combined with __Host- and RequireSameOrigin, not as a standalone guarantee.
const ( ReasonUserNotFound = "user_not_found" ReasonNoPassword = "no_password" ReasonWrongPassword = "wrong_password" ReasonAccountDisabled = "account_disabled" ReasonAccountLocked = "account_locked" ReasonEmailNotVerified = "email_not_verified" ReasonFactorCheckFailed = "factor_check_failed" ReasonTokenInvalid = "token_invalid" ReasonTokenExpired = "token_expired" ReasonTokenAlreadyUsed = "token_already_used" ReasonUserMismatch = "user_mismatch" ReasonBindingMismatch = "binding_mismatch" ReasonHashFailed = "hash_failed" ReasonStoreFailed = "store_failed" ReasonPasswordChanged = "password_changed" ReasonIdleTimeout = "idle_timeout" ReasonAbsoluteExpiry = "absolute_expiry" ReasonCSRFTokenInvalid = "csrf_token_invalid" // #nosec G101 -- a reason label, not a credential ReasonCrossSite = "cross_site" ReasonOriginNotAllowed = "origin_not_allowed" )
Reason labels, the closed set of values MetaReason can carry. They are fixed strings chosen by this package: never an error message, never anything a caller supplied.
const ( ScopeSingleSession = "single" ScopeAllSessions = "all" )
Values for MetaScope on EventSessionRevoked.
const ( DimensionAccount = "account" DimensionIP = "ip" )
Values for MetaDimension on EventRateLimitTripped.
Variables ¶
var ( // User errors. ErrUserNotFound = errors.New("sulis: user not found") ErrUserAlreadyExists = errors.New("sulis: user already exists") // ErrConcurrentUpdate is returned by UserStore.UpdateUser when the write // was built from a stale read and another writer won the race. ErrConcurrentUpdate = errors.New("sulis: concurrent update") // Credential errors. ErrInvalidCredentials = errors.New("sulis: invalid credentials") // Authentication errors. // // ErrNotAuthenticated is returned by IssueSession when given the zero // value Authentication{} (or any Authentication not obtained by // completing a factor sulis itself verified, since nothing outside this // package can construct one otherwise). It means there is no proof of // authentication to act on, not that a specific credential was wrong. ErrNotAuthenticated = errors.New("sulis: not authenticated") // Session errors. ErrSessionNotFound = errors.New("sulis: session not found") ErrSessionExpired = errors.New("sulis: session expired") // ErrReauthRequired is returned by RequireRecentAuth when a session's // AuthenticatedAt is older than the caller's maxAge. It means the // session is otherwise valid — ValidateSession would still accept it — // but too stale to authorize a step-up-gated operation without proving // the credential again via ReAuthenticate. ErrReauthRequired = errors.New("sulis: recent authentication required") // Token errors. ErrTokenInvalid = errors.New("sulis: invalid token") ErrTokenNotFound = errors.New("sulis: token not found") ErrTokenExpired = errors.New("sulis: token expired") ErrTokenAlreadyUsed = errors.New("sulis: token already used") // Password policy errors. ErrPasswordTooShort = errors.New("sulis: password too short") ErrPasswordTooLong = errors.New("sulis: password too long") // ErrPasswordCompromised is returned by every path that sets a password // — Register, ChangePassword, ResetPassword, SetInitialPassword — when // the configured PasswordChecker recognises the password as commonly // used, expected, or previously breached. It is never returned by // VerifyPassword, Login, or ReAuthenticate: see WithPasswordChecker for // why screening happens where a password is chosen and not where it is // proven. // // It is the very same error value as passwordcheck.ErrCompromised, not a // copy of it, so errors.Is matches under either name. The value has to be // born in that package rather than here: sulis's default configuration // constructs a passwordcheck.Blocklist, so an import in the other // direction would be a cycle, and two separate sentinels would silently // break errors.Is for anyone who compared against the wrong one. ErrPasswordCompromised = passwordcheck.ErrCompromised // Email validation errors. ErrInvalidEmail = errors.New("sulis: invalid email") // Email verification errors. ErrEmailNotVerified = errors.New("sulis: email not verified") // Rate limiting. ErrRateLimited = errors.New("sulis: rate limited") // Account status errors. // // ErrAccountDisabled is returned once a credential has verified for an // account DisableUser marked disabled — VerifyPassword checks this only // after a successful password verification, so a caller who has not // proven the password cannot use it to learn whether an account exists // and is disabled. ValidateSession also returns it for a pre-existing // session belonging to a disabled account, so disabling takes effect on // every live session immediately rather than only on the next login. ErrAccountDisabled = errors.New("sulis: account disabled") // ErrAccountLocked is returned the same way — only after a credential // has verified — for an account whose LockedUntil (set by the optional // automatic lockout; see WithFailureLockout) has not yet passed. Unlike // ErrAccountDisabled, it is not checked by ValidateSession: a lockout // throttles new authentication attempts, it does not invalidate a // session already issued before the lockout began. ErrAccountLocked = errors.New("sulis: account locked") // ErrCSRFTokenInvalid is returned by VerifyCSRFToken (and so by the // RequireCSRFToken middleware built on it) when the double-submit CSRF // cookie is missing, the client echoed nothing back in the header or // form field, or the two values don't match. It deliberately doesn't // distinguish those cases: telling an attacker which one failed would // hand back a bit of information about a cookie they can't otherwise // read. ErrCSRFTokenInvalid = errors.New("sulis: csrf token invalid") )
Functions ¶
func IssueCSRFToken ¶
IssueCSRFToken generates a new random CSRF token for the double-submit pattern described above and returns both the raw value — embed it in a hidden form field, or hand it to a same-origin script that will set CSRFHeaderName itself on the requests it makes — and the cookie to set alongside it (http.SetCookie(w, cookie)).
Call it once per session (right after SessionCookie, at login, is the natural place) or once per page/form render; either works, since VerifyCSRFToken only ever compares against whatever value is currently in the cookie, not anything remembered server-side.
func RequireCSRFToken ¶
RequireCSRFToken returns middleware enforcing the double-submit check (see VerifyCSRFToken) on every state-changing request — any method other than GET/HEAD/OPTIONS; safe methods pass through untouched, same as RequireSameOrigin.
Apply this to routes reachable via a cookie-authenticated session; a route reachable only via an Authorization: Bearer header (see WithTokenSource(TokenSourceBearerOnly)) gains nothing from it. It emits no security event: a package-level function has no Sulis and so no configured EventSink to emit to. Use the identically-behaved (*Sulis).RequireCSRFToken method instead if you want rejections to reach your sink as EventCSRFRejected.
func RequireSameOrigin ¶
RequireSameOrigin returns middleware that rejects a cross-site, state-changing request (any method other than GET/HEAD/OPTIONS) using the Fetch Metadata Sec-Fetch-Site header, falling back to Origin when Sec-Fetch-Site is absent. allowed lists origins — scheme://host[:port], e.g. "https://app.example.com" — that are trusted even when the browser reports (or Origin implies) a cross-site request; include every origin your own frontend is actually served from if it differs from the API's origin.
This is a CSRF defense for cookie-authenticated routes; apply it (and/or RequireCSRFToken) to any route reachable via a cookie-sourced session. It costs nothing extra on a Bearer-only route, but such a route gains nothing from it either — see the README's "Cookie sessions and CSRF" section.
Decision on missing headers (recorded in PROGRESS.md's T507 Decisions): when BOTH Sec-Fetch-Site and Origin are absent, the request is allowed through. Every browser new enough to send either header will send at least one of them on a cross-site request; a request with neither is the signature of a non-browser client — a Bearer-token API caller, in particular, which is not CSRF-exploitable in the first place, since a browser never attaches a Bearer header to a request on its own. Rejecting on absence would block exactly that population for no CSRF benefit. The residual gap this leaves — a pre-Fetch-Metadata browser that also omits Origin on some cross-site state-changing request — is the reason RequireCSRFToken exists as defense in depth: it does not depend on either header at all. It emits no security event: a package-level function has no Sulis and so no configured EventSink to emit to. Use the identically-behaved (*Sulis).RequireSameOrigin method instead if you want rejections to reach your sink as EventSameOriginRejected.
func VerifyCSRFToken ¶
VerifyCSRFToken implements the double-submit comparison at the heart of RequireCSRFToken: the value in the CSRFCookieName cookie must be present and must match, byte for byte, whatever the client echoed back — checked first in the CSRFHeaderName header, then (for a traditional <form> POST that can't set a custom header) the CSRFFormField form value. A missing cookie, a missing echoed value, and a mismatch all return the same ErrCSRFTokenInvalid.
This check alone is a pure double-submit — not bound to the session, only to whoever can read this cookie — see the package doc comment above for why that's layered with the __Host- prefix and RequireSameOrigin rather than relied on in isolation.
The comparison is constant-time (crypto/subtle.ConstantTimeCompare), so a timing side channel can't be used to recover the token byte by byte. This is asserted by TestVerifyCSRFTokenUsesConstantTimeCompare (csrf_test.go) via implementation inspection — it greps this file's source for the subtle.ConstantTimeCompare call — rather than by timing the comparison directly: a real timing test is inherently flaky on a shared CI runner, and would either flake occasionally or need enough slack to stop actually testing anything. The mutation this guards against: replacing the call below with a data-dependent comparison (cookie.Value == sent, or bytes.Equal) still passes every functional test above but fails this one, which is the point.
FormValue parses the request body when its Content-Type is application/x-www-form-urlencoded or multipart/form-data (and only then — see net/http's ParseForm), so calling this before a JSON handler reads r.Body is safe; calling it before a form handler reads r.Body directly is not, for the same reason any Go form-handling code already has to call ParseForm before touching the raw body once.
Types ¶
type Argon2Params ¶
type Argon2Params struct {
Memory uint32 // memory in KiB (default: 64 * 1024)
Iterations uint32 // time parameter (default: 3)
Parallelism uint8 // threads (default: 2)
SaltLength uint32 // bytes (default: 16)
KeyLength uint32 // bytes (default: 32)
}
Argon2Params holds the parameters for argon2id password hashing.
type AuthMethod ¶
type AuthMethod string
AuthMethod names the credential that authenticated a session.
const ( AuthMethodPassword AuthMethod = "password" AuthMethodMagicLink AuthMethod = "magic_link" AuthMethodPasskey AuthMethod = "passkey" AuthMethodTwoFactor AuthMethod = "two_factor" AuthMethodRecoveryCode AuthMethod = "recovery_code" )
type Authentication ¶
type Authentication struct {
// contains filtered or unexported fields
}
Authentication is opaque proof that a user has completed authentication — every factor sulis itself verified, not merely a caller's say-so. Its fields are unexported and there is no exported constructor that takes a bare user ID, so nothing outside this package can produce a valid value.
The zero value carries no user ID. IssueSession rejects it (and any other invalid Authentication) with ErrNotAuthenticated rather than treating an empty user ID as real, so a forgotten or zeroed proof fails loudly instead of silently minting a session for whichever account that empty string happens to resolve to.
completeFirstFactor mints one internally when a first factor is verified and no second factor is enrolled; CompleteTwoFactor mints one once the second factor is verified too. Neither currently routes through IssueSession itself — both already hold the *User in hand and call createSession/issueSessionForUser directly, avoiding the redundant store round trip IssueSession's user-ID-only input would otherwise force — but the type exists so that, in code rather than only in a doc comment, "this user is authenticated" is a value only this package can produce.
type Budget ¶
Budget describes how many attempts a key may make and how fast the allowance refills. Burst is both the bucket size and the number of attempts available after a long idle period; one token is restored every Interval.
type Config ¶
type Config struct {
SessionDuration time.Duration // how long sessions are valid (default: 24h)
TokenDuration time.Duration // how long password reset tokens are valid (default: 1h)
// MagicLinkDuration is how long magic-link tokens are valid (default:
// 15m), independent of TokenDuration — see WithMagicLinkDuration.
MagicLinkDuration time.Duration
TwoFactorTokenDuration time.Duration // how long two-factor pending-login tokens are valid (default: 5m)
EmailVerificationTokenDuration time.Duration // how long email verification tokens are valid (default: 24h)
SessionTokenBytes int // length of random session tokens in bytes (default: 32)
ResetTokenBytes int // length of random reset/magic link tokens in bytes (default: 32)
RevokeSessionsOnPasswordChange bool // revoke all sessions when a password is changed or reset (default: true)
RequireVerifiedEmail bool // block new sessions for unverified accounts (default: true)
// MagicLinkBinding requires RedeemMagicLink to be called with the
// bindingNonce CreateMagicLinkToken returned alongside the token
// (default: true) — see WithMagicLinkBinding.
MagicLinkBinding bool
MinPasswordLength int // minimum accepted password length in bytes (default: 12)
MaxPasswordLength int // maximum accepted password length in bytes (default: 1024)
Argon2 Argon2Params
Limiter Limiter // rate limiter consulted at guessable choke points (default: an in-process MemoryLimiter)
PasswordChecker PasswordChecker // screens new passwords for known-compromised values (default: passwordcheck.NewBlocklist())
// Pepper is mixed into every password via HMAC-SHA256 before Argon2 —
// see WithPepper. Default: nil, meaning no pepper.
Pepper []byte
// FailureLockoutThreshold, FailureLockoutBaseBackoff, and
// FailureLockoutMaxBackoff configure the optional automatic-lockout
// mechanism (see WithFailureLockout). FailureLockoutThreshold of 0
// (the default) disables it entirely: VerifyPassword never writes
// FailedLoginAttempts or LockedUntil.
FailureLockoutThreshold int
FailureLockoutBaseBackoff time.Duration
FailureLockoutMaxBackoff time.Duration
// IdleTimeout, if positive, is how long a session may go unused before
// ValidateSession rejects it with ErrSessionExpired — independent of,
// and typically much shorter than, SessionDuration. Zero (the default)
// disables idle expiry entirely: sessions live until SessionDuration
// regardless of use. See WithIdleTimeout.
IdleTimeout time.Duration
// CookieName is the name Authenticate reads the session token from
// (when TokenSource permits a cookie) and SessionCookie/
// ClearSessionCookie set (default: "__Host-session"). See
// WithCookieName.
CookieName string
// TokenSource controls which channel(s) Authenticate accepts a session
// token from (default: TokenSourceBoth). See WithTokenSource.
TokenSource TokenSource
// EventSink receives security events — every security-relevant
// decision this package makes. Default: nil, meaning nothing is
// emitted. See WithEventSink and events.go.
EventSink EventSink
}
Config holds the configuration for a Sulis instance.
type Event ¶
type Event struct {
// Kind is which decision this is. Always set.
Kind EventKind
// UserID is the account the decision concerns, when one is known.
// Empty for decisions made before an account is identified (a login for
// an unknown address, a magic link for an address with no account yet,
// a rejected pending token) and for the HTTP middleware rejections,
// which happen before any session is validated.
UserID string
// SessionID is the session the decision concerns, when one is
// relevant: the session issued, revoked, refreshed, expired, or
// re-authenticated. Empty otherwise. It is the session's row ID, never
// its token or the hash of its token.
SessionID string
// RequestInfo is what the calling application reported about the
// request, passed straight through from the flow's own RequestInfo
// argument. Flows that take no RequestInfo leave it zero. The one
// exception is the HTTP middleware ((*Sulis).RequireCSRFToken and
// (*Sulis).RequireSameOrigin), which has an *http.Request in hand and
// fills in the transport peer address and User-Agent itself — see
// requestInfoFromRequest for why that address is the direct peer and
// not an X-Forwarded-For-resolved client.
RequestInfo RequestInfo
// At is when the decision was made, stamped at emission.
At time.Time
// Metadata carries the narrow, fixed labels listed under MetadataKey —
// a reason, an auth method, a scope, a dimension. Nil when the kind
// says everything there is to say. It is never a place to put payloads,
// caller input, or error text.
Metadata map[MetadataKey]string
}
Event is one security-relevant decision, as reported to an EventSink.
Every field is either an identifier this package generated, a timestamp, a RequestInfo the caller explicitly supplied, or a label drawn from the closed sets above.
The no-secrets rule ¶
No event ever carries credential material. There is deliberately no field on Event that could hold one: no token, no password, no hash, no nonce. Beyond that, this package never copies ANY caller-supplied string into an event except the RequestInfo the caller explicitly passed for this purpose. In particular an event never carries:
- a raw password, session token, reset/magic-link/two-factor/email token, or magic-link binding nonce;
- a stored password hash or session token hash;
- the submitted email address (people type passwords into the email field, and an event taxonomy that copies caller input is one bad day away from being a credential log);
- the operator-supplied reason passed to DisableUser, for the same reason.
Accounts are identified by UserID, sessions by SessionID. Both are opaque identifiers this package generated; neither authenticates anything on its own (see SessionStore.DeleteSession for why knowing a session ID is not enough to act on it). The rule is enforced by test, not only by convention — see TestNoEventCarriesSecretMaterial in events_test.go, which drives every emitting flow and scans every field of every emitted event for every secret those flows were fed.
type EventKind ¶
type EventKind string
EventKind names one security-relevant decision. The values are stable, lowercase, dot-namespaced strings safe to use as log field values, metric labels, or database enum entries.
Each constant repeats the EventKind type deliberately, rather than leaning on a const block carrying the type down the list: the completeness test (TestEveryDeclaredEventKindIsEmitted) reads them out of this file's source, so every declaration has to look the same.
const ( // EventAccountRegistered reports that Register created an account. EventAccountRegistered EventKind = "account.registered" // EventLoginSucceeded reports that a password verified — VerifyPassword // completed, including its account-status and lockout checks. It does // NOT mean a session exists: a user with an enrolled second factor gets // EventSecondFactorDemanded next, and only EventSessionIssued means a // session was actually minted. Carries MetaMethod. EventLoginSucceeded EventKind = "login.succeeded" // EventLoginFailed reports that an authentication attempt was refused — // a wrong or missing credential, or a gate (disabled, locked, // unverified email, an unavailable second-factor checker) refusing an // otherwise-correct one. Carries MetaReason and MetaMethod. UserID is // empty when the address matched no account. EventLoginFailed EventKind = "login.failed" // EventPasswordChanged reports a successful ChangePassword. EventPasswordChanged EventKind = "password.changed" // EventPasswordSet reports a successful SetInitialPassword — a // previously passwordless account gaining its first password. EventPasswordSet EventKind = "password.set" // EventPasswordResetRequested reports that CreatePasswordResetToken (or // CreatePasswordResetTokenStrict) issued a reset token. The // unknown-address branch emits nothing: it changes no state, and an // event there would be a server-side record of addresses that do not // exist. Reset flooding is visible through EventRateLimitTripped on the // "reset" scope instead. EventPasswordResetRequested EventKind = "password.reset_requested" // EventPasswordReset reports a successful ResetPassword. EventPasswordReset EventKind = "password.reset" // EventPasswordRehashed reports that a stored hash was upgraded on a // successful verification — because it was weaker than the configured // Argon2Params, or because it predated NFKC normalization. This is what // makes "did raising Argon2Params actually reach the installed base?" // an answerable question. EventPasswordRehashed EventKind = "password.rehashed" // EventPasswordRehashFailed reports that such an upgrade was attempted // and did not land. The login itself succeeded regardless — the upgrade // is best effort and its failure is deliberately swallowed (see // rehashPassword) — so this event is the only trace it left. Carries // MetaReason: ReasonHashFailed, ReasonStoreFailed, or // ReasonPasswordChanged. EventPasswordRehashFailed EventKind = "password.rehash_failed" // EventPasswordLegacyFormMatched reports that a password verified only // through verifyPassword's pre-NFKC compatibility fallback: the stored // hash was written before normalization existed. It is followed by an // EventPasswordRehashed (or EventPasswordRehashFailed) for the same // account, because matching that way is exactly the moment to migrate // the hash. // // This event is what makes retiring that fallback answerable: when it // stops appearing for a deployment, every account has been migrated and // the fallback can go. Without it the fallback would have to stay // forever on the grounds that nobody can prove it is unused. EventPasswordLegacyFormMatched EventKind = "password.legacy_form_matched" // EventSecondFactorDemanded reports that a verified first factor earned // a pending token rather than a session, because the account has a // second factor enrolled. Also emitted by CreateTwoFactorToken. EventSecondFactorDemanded EventKind = "twofactor.demanded" // EventSecondFactorCompleted reports a successful CompleteTwoFactor. EventSecondFactorCompleted EventKind = "twofactor.completed" // EventSecondFactorFailed reports that CompleteTwoFactor refused — // an unknown, expired, already-used, or wrong-purpose pending token, a // token belonging to a different user, or a gate refusing the account. // Carries MetaReason. EventSecondFactorFailed EventKind = "twofactor.failed" // EventSessionIssued reports that a session row was created, by any // path: Register, Login, a redeemed magic link, CompleteTwoFactor, // IssueSession, or IssueSessionUnchecked. Carries MetaMethod and the // new session's SessionID. This is the method-agnostic "somebody is now // signed in" signal. EventSessionIssued EventKind = "session.issued" // EventSessionRevoked reports a successful RevokeSession (MetaScope // ScopeSingleSession, with SessionID set) or RevokeAllSessions // (MetaScope ScopeAllSessions, with SessionID empty). EventSessionRevoked EventKind = "session.revoked" // EventSessionRefreshed reports a successful RefreshSession. SessionID // is the NEW session's ID — RefreshSession mints a new row rather than // rewriting the old one. EventSessionRefreshed EventKind = "session.refreshed" // EventSessionExpired reports that ValidateSession rejected and deleted // a session past its absolute ExpiresAt. EventSessionExpired EventKind = "session.expired" // EventSessionIdleExpired reports that ValidateSession rejected and // deleted a session past its IdleExpiresAt — the idle timeout // configured by WithIdleTimeout, checked before absolute expiry. EventSessionIdleExpired EventKind = "session.idle_expired" // EventEmailChangeStaged reports that ChangeEmail staged a new address // and issued a confirmation token. The address itself is not in the // event; see Event's doc comment for the no-secrets rule. EventEmailChangeStaged EventKind = "email.change_staged" // EventEmailChangeConfirmed reports that ConfirmEmailChange made a // staged address live, revoking the account's sessions in the process. EventEmailChangeConfirmed EventKind = "email.change_confirmed" // EventEmailVerified reports that an address was verified for the first // time, by VerifyEmail or by a redeemed magic link. The idempotent // re-verification of an already-verified address emits nothing, because // nothing was decided. EventEmailVerified EventKind = "email.verified" // EventMagicLinkCreated reports that CreateMagicLinkToken issued a // link. UserID is empty when the address has no account yet — the user // is created at redemption. EventMagicLinkCreated EventKind = "magiclink.created" // EventMagicLinkRedeemed reports that a magic-link token was consumed // and, when binding is enabled, matched its binding nonce. It is the // magic-link counterpart of EventLoginSucceeded: proof of mailbox // control, not proof that a session followed. EventMagicLinkRedeemed EventKind = "magiclink.redeemed" // EventMagicLinkRejected reports that RedeemMagicLink refused — an // unknown, expired or already-used token, or a missing or wrong binding // nonce. Carries MetaReason. A ReasonBindingMismatch here is the // signal that a link was clicked somewhere other than the browser that // asked for it: forwarded, prefetched, or stolen. EventMagicLinkRejected EventKind = "magiclink.rejected" // EventRateLimitTripped reports that the configured Limiter denied a // key. Carries MetaScope (the choke point: "password", "reset", // "magic") and MetaDimension (DimensionAccount or DimensionIP). The // limiter key itself is never in the event — it embeds an email // address. EventRateLimitTripped EventKind = "ratelimit.tripped" // EventAccountDisabled reports a successful DisableUser. The // operator-supplied reason is deliberately not carried. EventAccountDisabled EventKind = "account.disabled" // EventAccountEnabled reports a successful EnableUser. EventAccountEnabled EventKind = "account.enabled" // EventAccountLocked reports that the optional automatic lockout (see // WithFailureLockout) set or extended a LockedUntil deadline after a // failed password attempt. EventAccountLocked EventKind = "account.locked" // EventAccountLockoutCleared reports that a correct password outside // any active lockout window cleared the stale failure count and // deadline. EventAccountLockoutCleared EventKind = "account.lockout_cleared" // EventReauthSucceeded reports a successful ReAuthenticate — the // step-up gate RequireRecentAuth checks was refreshed. EventReauthSucceeded EventKind = "reauth.succeeded" // EventReauthFailed reports that ReAuthenticate refused. Carries // MetaReason. A burst of these against one session is a stolen-cookie // signal: whoever holds the session does not know the password. EventReauthFailed EventKind = "reauth.failed" // EventCSRFRejected reports that (*Sulis).RequireCSRFToken's // double-submit check refused a state-changing request. Emitted only by // the Sulis-bound middleware; the package-level RequireCSRFToken has no // sink to emit to. EventCSRFRejected EventKind = "csrf.rejected" // EventSameOriginRejected reports that (*Sulis).RequireSameOrigin // refused a state-changing request as cross-site (ReasonCrossSite, from // Sec-Fetch-Site) or as carrying an unlisted Origin // (ReasonOriginNotAllowed). Emitted only by the Sulis-bound middleware; // the package-level RequireSameOrigin has no sink to emit to. EventSameOriginRejected EventKind = "sameorigin.rejected" )
type EventSink ¶
EventSink receives security events.
Emit returns nothing on purpose: a sink has no way to fail a flow, so there is no error for this package to propagate and no temptation to propagate one. Implementations must be safe for concurrent use — Emit is called from whatever goroutine is running the flow — and should return quickly, doing anything slow or fallible elsewhere. Emit is called AFTER the decision it reports.
A panicking Emit is recovered, and the panic dropped, so a broken sink cannot take authentication down with it — but that containment is itself silent: there is nowhere left to report the panic to, so a sink that panics gets no error, no log line, and no second call this time around. Do not rely on it. A sink should hand the event off — a channel, a logger, a buffer — and return, rather than doing anything slow or failure-prone inline.
func NewSlogSink ¶
NewSlogSink adapts a *slog.Logger to EventSink, so wiring security events into an application that already logs structurally is one line:
sulis.WithEventSink(sulis.NewSlogSink(logger))
Every event is logged at slog.LevelInfo with the message "sulis security event" and one attribute per populated field: kind, user_id, session_id, ip, user_agent, at, and one per Metadata entry (reason, method, scope, dimension). Empty fields are omitted rather than logged as "". Metadata attributes are emitted in sorted key order, so two events of the same kind produce the same attribute order.
A nil logger falls back to slog.Default rather than panicking on the first event — a forgotten logger should be a misconfiguration, not an outage.
type Limiter ¶
Limiter enforces a rate limit for a caller-supplied key. Implementations decide the algorithm, window, and storage (e.g. a token bucket backed by Redis or an in-memory store). Allow returns a non-nil error if the key should be denied.
type LoginResult ¶
type LoginResult struct {
User *User
Session *Session
SessionToken string
NeedsSecondFactor bool
PendingToken string
}
LoginResult is the outcome of a successful first factor.
Exactly one outcome is populated. When NeedsSecondFactor is true, Session and SessionToken are empty and PendingToken holds a short-lived, single-use token to pass to CompleteTwoFactor once the application has verified the second factor. Otherwise Session and SessionToken hold a live session and its raw token, and PendingToken is empty.
Callers must branch on NeedsSecondFactor. Treating a non-nil LoginResult as "logged in" defeats two-factor authentication.
type MemoryLimiter ¶
type MemoryLimiter struct {
// contains filtered or unexported fields
}
MemoryLimiter is a per-process token-bucket Limiter. It is the default, so that a Sulis built with no options still resists guessing — a library whose documentation has to ask for rate limiting is a library that mostly runs without it.
It is per-process: with several instances behind a load balancer, each enforces its own budget. Replace it with a shared implementation (Redis or similar) via WithLimiter for a multi-instance deployment.
A single MemoryLimiter satisfies sulis.Limiter, totp.Limiter and recovery.Limiter, which are structurally identical, so one instance can guard all three packages. That identity is compiler-enforced rather than hoped for: see the assignability declarations at the top of limiter_test.go.
func NewMemoryLimiter ¶
func NewMemoryLimiter(opts ...MemoryLimiterOption) *MemoryLimiter
NewMemoryLimiter creates a token-bucket limiter with the default budgets.
type MemoryLimiterOption ¶
type MemoryLimiterOption func(*MemoryLimiter)
MemoryLimiterOption configures a MemoryLimiter.
func WithBudget ¶
func WithBudget(prefix string, b Budget) MemoryLimiterOption
WithBudget sets the budget for keys carrying the given prefix. The longest matching prefix wins.
func WithMaxTrackedKeys ¶
func WithMaxTrackedKeys(n int) MemoryLimiterOption
WithMaxTrackedKeys bounds how many distinct keys are held in memory. A limiter that can be driven out of memory is a denial of service rather than a defence, so tracking is capped and the least recently used keys are dropped once the cap is reached.
type MetadataKey ¶
type MetadataKey string
MetadataKey is a key in Event.Metadata. The set is closed — these four constants are the only keys this package ever writes — so a sink can index on them without pattern-matching free-form strings, and a reviewer can see at a glance everything an event can say beyond its kind.
const ( // MetaReason says why a decision went the way it did. Its value is // always one of the Reason constants below: a fixed label chosen by // this package, never caller input and never an error string. MetaReason MetadataKey = "reason" // MetaMethod is an AuthMethod value — which credential is involved. MetaMethod MetadataKey = "method" // MetaScope narrows the kind. On EventSessionRevoked it is // ScopeSingleSession or ScopeAllSessions; on EventRateLimitTripped it // is the choke point whose budget was exhausted ("password", "reset", // "magic"). MetaScope MetadataKey = "scope" // MetaDimension is DimensionAccount or DimensionIP, on // EventRateLimitTripped: which of the limiter's two keys denied. The // distinction is the whole reason both keys exist — one account being // guessed is a different incident from one host spraying many // accounts. MetaDimension MetadataKey = "dimension" )
type NoSecondFactors ¶
type NoSecondFactors struct{}
NoSecondFactors is an explicit declaration that an application has no second factors at all. Prefer it over a hand-written stub, so the intent is greppable.
func (NoSecondFactors) HasSecondFactor ¶
HasSecondFactor always reports false.
type Option ¶
type Option func(*Config)
Option is a functional option for configuring Sulis.
func WithArgon2Params ¶
func WithArgon2Params(p Argon2Params) Option
WithArgon2Params sets custom argon2id parameters for password hashing.
func WithCookieName ¶
WithCookieName overrides the session cookie's name (default: "__Host-session"). New rejects a name that isn't a valid HTTP cookie token (empty, or containing whitespace/control/separator characters).
Choosing a name without the "__Host-" prefix is a valid, explicit opt-out of that browser-enforced guarantee (see defaultCookieName) — do this only if you have a concrete reason to (for instance, sharing the cookie across subdomains via an explicit Domain your own reverse proxy adds, which this package's cookies never set themselves). Secure, Path=/, and HttpOnly are set on SessionCookie/ClearSessionCookie regardless of name: nothing in this package's configuration surface can turn them off.
func WithEmailVerificationTokenDuration ¶
WithEmailVerificationTokenDuration sets how long email verification tokens remain valid.
func WithEventSink ¶
WithEventSink routes security events to sink. Every security-relevant decision this package makes — a password refused, a second factor demanded, a session issued or expired, a limiter tripped, an account disabled, and more — is reported to it. See EventKind's constants for the full taxonomy and Event's doc comment for what a reported event may and may not contain.
The default is nil: no sink, no events, and nothing on any flow's hot path but a nil check. That is not just a description of the default — arguments are evaluated before a call, so an event's Metadata map is built only after the nil-sink check inside emit, never at the call site where it would be allocated on every decision whether or not anybody was listening. TestNilSinkPathAllocatesNothing (events_test.go) holds that guarantee to account with testing.AllocsPerRun.
The one-line wiring for an application that already has a *slog.Logger:
auth, err := sulis.New(users, sessions, tokens, factors,
sulis.WithEventSink(sulis.NewSlogSink(logger)))
func WithFailureLockout ¶
WithFailureLockout enables automatic, temporary lockout after threshold consecutive wrong passwords for one account. Once threshold is reached, VerifyPassword sets User.LockedUntil to baseBackoff after the moment of the triggering failure; every further wrong password while still locked pushes LockedUntil out again, doubling the backoff each time, up to maxBackoff. The lockout — and the failure count behind it — clears itself automatically the next time a correct password verifies outside the window, OR the account's password is successfully changed or reset (ChangePassword, ResetPassword, SetInitialPassword) — proving control of the account well enough to set a new password is at least as strong an identity proof as the login password itself. There is no explicit unlock call for either path. DisableUser/EnableUser remain available for an operator-initiated block, which is a distinct, unrelated mechanism that none of the above clears — a password reset lifts an automatic lockout, never a manual disable; only EnableUser does that (see the README's "Account disable and lockout" section).
Default: disabled (threshold 0), so a Sulis built with no options never writes FailedLoginAttempts or LockedUntil, and VerifyPassword's normal path pays no extra store round trip.
Off by default deliberately: this locks out the legitimate account owner exactly as effectively as it locks out an attacker, so an attacker who merely knows (or guesses) an email address can weaponize it as a denial-of-service against that account — a failure mode the rate limiter (on by default; see WithLimiter/MemoryLimiter) does not share, since it throttles the guesser without touching the account's own ability to log in once its window passes. Enable this only if your threat model needs an escalating response beyond rate limiting, and prefer a long baseBackoff/ maxBackoff pair over a short one: the whole point is to make continued guessing expensive without approaching a permanent lock a legitimate owner could not eventually recover from on their own.
func WithIdleTimeout ¶
WithIdleTimeout enables idle expiry: a session unused for longer than d is rejected by ValidateSession with ErrSessionExpired, even if its absolute SessionDuration lifetime has not yet elapsed. "Unused" is tracked via Session.LastSeenAt/IdleExpiresAt, refreshed by ValidateSession on a throttled cadence (see sessionTouchInterval in session.go) rather than on every single call — the idle deadline can therefore lag true last-use by up to that interval, which trades a small amount of precision for not writing to the session store on every authenticated request.
Passing d <= 0 disables idle expiry — the default, so a Sulis built with no options never checks or writes IdleExpiresAt at all.
func WithLimiter ¶
WithLimiter replaces the rate limiter consulted at guessable authentication choke points: password verification, and password reset / magic link token issuance. The default is an in-process MemoryLimiter; supply a shared implementation (Redis or similar) when running more than one instance, since the default enforces its budget per process.
Passing nil disables rate limiting, but prefer WithoutRateLimiting, which says so in code.
func WithMagicLinkBinding ¶
WithMagicLinkBinding controls whether redeeming a magic link requires a binding nonce matching the one CreateMagicLinkToken generated alongside the token (default: true).
CreateMagicLinkToken returns (token, bindingNonce string, err error). The application is expected to set bindingNonce as a short-lived, HttpOnly cookie on the response to the request that triggered issuance — NOT to embed it in the emailed link itself, which would defeat the entire point — and to read it back from that cookie when the link is later clicked, passing it to RedeemMagicLink alongside the token recovered from the link's query string. Because the nonce travels only in a cookie scoped to the browser that requested the link, a copy of the link forwarded to, or opened by, a different device or browser arrives without the matching cookie: RedeemMagicLink then rejects it with ErrTokenInvalid even though the token itself is still valid, unused, and unexpired. That is what makes a forwarded magic link useless to whoever it was forwarded to.
The nonce is stored hashed (SHA-256, alongside the token's own hash — see Token.NonceHash), never in plaintext, and compared at redemption via crypto/subtle.ConstantTimeCompare over the hashes, exactly as VerifyCSRFToken compares its own double-submit token.
Passing false accepts any bindingNonce value at redemption — including "" — because CreateMagicLinkToken stops generating one at all: it returns bindingNonce == "" and the created Token carries no NonceHash for RedeemMagicLink to check against. The trade-off: without binding, a magic link works from whatever device or browser opens it, which is convenient when mail is routinely read somewhere other than where the link was requested (a common case — requesting from a desktop, opening from a phone's mail app) — but it also means a link forwarded to someone else, or consumed by an automated mail scanner that prefetches links before a human ever clicks, signs that other party or scanner in instead. Turning this off is a deliberate, greppable trade-off; make it with that risk in mind, not by leaving it at the default without thinking about it. See the README's magic-link section for the prefetch hazard and why a confirmation click (rather than a bare GET link) is recommended regardless of this setting.
func WithMagicLinkDuration ¶
WithMagicLinkDuration sets how long magic-link tokens remain valid (default: 15m). This is independent of TokenDuration/WithTokenDuration, which governs password-reset tokens only: a magic link is a full credential delivered in cleartext over email — where it can be forwarded, scanned by a mail security appliance, or prefetched by a client before the recipient ever sees it — so it should live for only as long as a legitimate recipient plausibly needs to click it, not as long as a password-reset link a human reads and then types a new password after. The previous behavior, before this option existed, was both flows sharing TokenDuration (default 1h); a deployment relying on that 1h magic-link window must now set WithMagicLinkDuration(time.Hour) explicitly.
func WithPasswordChecker ¶
func WithPasswordChecker(c PasswordChecker) Option
WithPasswordChecker replaces the checker consulted on every password-setting path — Register, ChangePassword, ResetPassword, SetInitialPassword — after the length policy passes and before the password is hashed. A checker that returns ErrPasswordCompromised rejects the password; any other error is an operational failure and propagates to the caller unchanged.
The default is passwordcheck.NewBlocklist(), an embedded corpus of the ten thousand most common passwords: no network, no third party, nothing to configure, and on by default because a check that has to be discovered in documentation mostly does not run. To also query Have I Been Pwned, compose rather than replace — passing the HIBP checker alone silently drops the local blocklist:
sulis.WithPasswordChecker(passwordcheck.All( passwordcheck.NewBlocklist(), passwordcheck.NewHIBP(), ))
Passing nil disables password checking entirely, which is the right call only when something outside sulis already screens passwords.
The checker is deliberately NOT consulted by VerifyPassword, Login, or ReAuthenticate. Screening at verification time would lock out every existing user whose password happens to be in the corpus the moment one is added or refreshed — turning a hardening change into a mass outage, and worse, one whose only remedy (a password reset) is itself a login-adjacent flow. A password is screened where it is chosen, not where it is proven. Applications that want existing users moved off a now-known-bad password should detect that out of band and require a change, which keeps the user in control of when it happens.
func WithPasswordLengthLimits ¶
WithPasswordLengthLimits sets the minimum and maximum accepted password length. Both bounds are measured in bytes (len(password)), not runes or characters — deliberately, to bound Argon2's input size regardless of encoding, so multi-byte UTF-8 passwords count for more than one unit per character.
The bytes counted are those of the NFKC-normalized password (see normalizePassword), because that is the string Argon2 actually consumes. Normalization can shorten a password — twelve fullwidth digits are 36 raw bytes and 12 normalized ones — so measuring the raw form would let a password through a minimum it does not actually meet.
The default minimum is 12. It was 8 before this series; NIST SP 800-63B treats 8 as the floor for a memorized secret and expects more from anything that is not backed by a second factor, and this series was already breaking the API. Lowering it is supported and sometimes right — a deployment where every account has a passkey or TOTP, for instance — and doing so makes the embedded blocklist (see WithPasswordChecker) matter far more, since most common passwords are shorter than 12 characters and are otherwise rejected by this policy before the checker ever sees them.
func WithPepper ¶
WithPepper sets a secret pepper mixed into every password via HMAC-SHA256 before Argon2 (see password.go's applyPepper). It protects against a database-only leak — a copy of the user table with no access to application config or secrets yields hashes nobody can run an offline dictionary attack against without also having the pepper. It does NOT protect against a full application compromise: the same process that hashes passwords holds the pepper, so an attacker who reaches that process reaches both.
Losing the pepper makes EVERY stored hash permanently unverifiable — there is no fallback, unlike a hash's own salt (which travels with the hash). Store it with the same care as a private key: outside version control, in a secrets manager or environment variable, never beside the database it is meant to protect.
The pepper is a first-deployment decision, not a knob to turn later. Setting one where there was none, changing its value, or clearing one that was set makes every hash written under the old configuration unverifiable: verifyPassword applies whichever pepper is CURRENTLY configured, uniformly, to both the NFKC and pre-NFKC forms its existing T505 legacy-fallback seam already tries (see the T505 Decisions row) — it does not also try "with each pepper this deployment has ever used" on top of that. Unlike T505's normalization fallback, which is safe to widen because it can only ever match the exact bytes a hash was already derived from, a pepper-introduced-later problem is symmetric with a pepper-changed or pepper-removed one: there is no single "old form" to fall back to, only an unbounded list of past values this library has no way to know. Introduce a pepper before the first password is ever hashed, or plan on resetting affected users' passwords when introducing one later — the same recovery path already used for a lost password, not a new failure mode.
func WithRequireVerifiedEmail ¶
WithRequireVerifiedEmail sets whether new sessions are blocked until the account's email is verified. Register's signup session and magic-link redemption (which verifies the email itself) are always exempt.
func WithRevokeSessionsOnPasswordChange ¶
WithRevokeSessionsOnPasswordChange controls whether all of a user's sessions are revoked when their password is changed or reset (default: true).
func WithSessionDuration ¶
WithSessionDuration sets how long sessions remain valid.
func WithTokenDuration ¶
WithTokenDuration sets how long password reset tokens remain valid. Magic-link tokens do NOT use this — they have their own, independent duration; see WithMagicLinkDuration.
func WithTokenSource ¶
func WithTokenSource(ts TokenSource) Option
WithTokenSource restricts which channel(s) Authenticate accepts a session token from (default: TokenSourceBoth). See TokenSource's own constants for what each value means and why TokenSourceBoth remains the default.
func WithTwoFactorTokenDuration ¶
WithTwoFactorTokenDuration sets how long two-factor pending-login tokens remain valid.
func WithoutRateLimiting ¶
func WithoutRateLimiting() Option
WithoutRateLimiting disables rate limiting entirely.
Rate limiting is on by default because a library that has to ask for it in its documentation mostly runs without it. Turning it off should therefore be a visible, greppable line in your code rather than the consequence of not writing one — for instance when an upstream gateway already enforces limits.
type PasswordChecker ¶
PasswordChecker screens a candidate password for known-compromised values, beyond what the length policy can judge. It is consulted on every path that sets a password — Register, ChangePassword, ResetPassword, SetInitialPassword — and never on a path that merely verifies one; see WithPasswordChecker.
Check receives the password in its NFKC-normalized form, which is exactly the string that will be hashed and stored. It returns nil if the password is acceptable, ErrPasswordCompromised (or an error wrapping it) to reject it, and any other error if it could not reach a verdict — that last case propagates to the caller unchanged and must not be presented to a user as "your password is compromised", because nobody actually looked.
Implementations must be safe for concurrent use. This is the same method set as passwordcheck.Checker, so the checkers in that package satisfy it directly and so does anything written against either interface.
type RequestInfo ¶
RequestInfo carries per-request caller context. It feeds the IP dimension of rate limiting and is recorded on sessions so users can recognise their own devices. The zero value is valid: callers with nothing to report pass RequestInfo{}.
type SecondFactorChecker ¶
type SecondFactorChecker interface {
HasSecondFactor(ctx context.Context, userID string) (bool, error)
}
SecondFactorChecker reports whether a user has an enrolled second factor.
It is a required argument to New rather than an option, because a default would silently answer "no" — and answering "no" by default is exactly the bypass this type exists to close. Applications that genuinely have no second factors pass NoSecondFactors{}, which says so in code rather than by omission.
Implementations should consult whatever the application treats as a second factor: a verified TOTP enrollment, a registered passkey, or both.
type Session ¶
type Session struct {
ID string
UserID string
// TokenHash is the SHA-256 hash of the session token. The raw token is
// never a field on this struct: it is returned beside the *Session at
// issue time and nowhere else, so no store can persist it by accident.
TokenHash string
ExpiresAt time.Time
CreatedAt time.Time
// AuthenticatedAt is when the credential behind this session was last
// proven — at issuance, and again on every successful ReAuthenticate.
// RequireRecentAuth compares it against a caller-supplied maxAge to gate
// security-sensitive operations (enrolling or replacing a second
// factor, removing a passkey, disabling 2FA, changing email,
// regenerating recovery codes — see the README) behind more than a
// bare, possibly hours-old session. A session issued before this field
// existed reads back as the zero time, which is always older than any
// maxAge, so RequireRecentAuth fails closed on it rather than treating
// an absent stamp as fresh.
AuthenticatedAt time.Time
// Method records which credential last authenticated this session —
// set at issuance from the AuthMethod the caller vouches for (or, for
// IssueSession, the one recorded on the Authentication proof) and left
// untouched by ReAuthenticate, which refreshes AuthenticatedAt only.
Method AuthMethod
// LastSeenAt records when this session was last used — stamped at
// issuance, and refreshed by ValidateSession via TouchSession while the
// session stays active. It is throttled, not written on every call: see
// sessionTouchInterval's doc comment for why. Useful for a
// device-management "last active" column; do not read it as
// precise-to-the-request.
LastSeenAt time.Time
// IdleExpiresAt is the deadline past which ValidateSession rejects this
// session with ErrSessionExpired even though ExpiresAt has not been
// reached yet — an idle-timeout, refreshed alongside LastSeenAt on the
// same throttled cadence. Nil means idle expiry is disabled for this
// session, which is the case for every session unless WithIdleTimeout
// is configured (the default).
IdleExpiresAt *time.Time
// IP and UserAgent are copied from the RequestInfo the issuing call
// received, so a "where you're signed in" screen can render something
// recognizable ("Chrome on a Lisbon IP", roughly). Only the
// issuance paths that take a RequestInfo populate them:
// Register/Login/RedeemMagicLink/CompleteTwoFactor. IssueSession and
// IssueSessionUnchecked have no RequestInfo in their Appendix A
// signatures, so sessions minted through them carry the zero value —
// see the PROGRESS.md Decisions row for T503.
IP string
UserAgent string
Metadata map[string]any
}
Session represents a server-side authentication session.
type SessionStore ¶
type SessionStore interface {
CreateSession(ctx context.Context, session *Session) error
GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error)
// ListUserSessions returns every session belonging to userID, in any
// order. Matching nothing is not an error — an empty (possibly nil)
// slice and a nil error.
//
// Returned sessions MUST be independent copies, the same no-aliasing
// rule CreateSession/GetSessionByTokenHash already follow: a caller
// mutating an entry in the returned slice must never reach the stored
// row. This includes TokenHash — this method returns it exactly as
// stored, the same as GetSessionByTokenHash does. Stripping it to ""
// before it reaches an application is Sulis.ListUserSessions's job,
// not this method's.
ListUserSessions(ctx context.Context, userID string) ([]Session, error)
// DeleteSession removes the session identified by id if it belongs to
// userID. The membership check and the removal MUST happen as a
// single atomic operation scoped to both columns:
//
// DELETE FROM sessions WHERE id = ? AND user_id = ?
//
// Zero rows affected — whether id does not exist at all, or exists
// but belongs to a different user — MUST return ErrSessionNotFound
// rather than succeeding silently. This is what makes cross-user
// revocation impossible through RevokeSession: it passes the
// caller's own userID, so guessing or leaking another user's session
// ID never deletes anything.
DeleteSession(ctx context.Context, userID, id string) error
DeleteUserSessions(ctx context.Context, userID string) error
// DeleteUserSessionsExcept removes every session belonging to userID
// except the one identified by keepSessionID, as a single operation:
//
// DELETE FROM sessions WHERE user_id = ? AND id <> ?
//
// This is the "sign out everywhere else" primitive: a device-management
// UI keeps the session the request making the call is itself using and
// revokes the rest. keepSessionID naming a session that does not exist,
// or one belonging to a different user, is not an error — every OTHER
// session for userID is removed regardless, matching
// DeleteUserSessions's "matching nothing is not an error" behavior for
// the degenerate all-sessions case. There is no Sulis-level wrapper for
// this method (see the PROGRESS.md Decisions row): Appendix A does not
// name one, and the facade-level path for the same outcome is
// ListUserSessions plus a RevokeSession per entry.
DeleteUserSessionsExcept(ctx context.Context, userID, keepSessionID string) error
CleanExpired(ctx context.Context) error
// UpdateAuthenticatedAt stamps the session identified by id with at,
// leaving every other field (including ExpiresAt and Method) untouched:
//
// UPDATE sessions SET authenticated_at = ? WHERE id = ?
//
// Zero rows affected — id does not exist — MUST return
// ErrSessionNotFound. This is the write path behind ReAuthenticate: it
// refreshes how recently a session's owner last proved their
// credential, without minting a new session or rotating its token, so
// a subsequent RequireRecentAuth call passes immediately afterward.
//
// It is deliberately its own method rather than an extra parameter on
// TouchSession's session-liveness "last seen" touch below: a step-up
// re-authentication and a liveness heartbeat are different events with
// different callers and different frequencies, and folding them into
// one call would make a caller that means to refresh only one of the
// two silently refresh both.
UpdateAuthenticatedAt(ctx context.Context, id string, at time.Time) error
// TouchSession stamps the session identified by id with a fresh
// lastSeen and idleExpires, leaving every other column (ExpiresAt,
// TokenHash, AuthenticatedAt, Method, IP, UserAgent, ...) untouched:
//
// UPDATE sessions SET last_seen_at = ?, idle_expires_at = ? WHERE id = ?
//
// idleExpires is nil whenever idle expiry is disabled (WithIdleTimeout
// not configured, the default). A nil idleExpires MUST be written as
// SQL NULL, clearing any previously-stored value — an application that
// enables idle expiry and later disables it again must not have a
// stale deadline linger and silently start enforcing itself once more.
//
// Zero rows affected — id does not exist — MUST return
// ErrSessionNotFound. This is the write path behind
// Sulis.ValidateSession's liveness touch, and it is deliberately
// throttled rather than called on every validation — see
// sessionTouchInterval's doc comment for the cost rationale.
TouchSession(ctx context.Context, id string, lastSeen time.Time, idleExpires *time.Time) error
}
SessionStore defines the persistence operations for sessions.
A store MUST NOT share mutable state with its callers in either direction. Metadata is a map, so copying a *Session with a plain struct assignment copies a map header rather than the map, leaving the caller holding a live handle on the stored session — and a session a caller can rewrite outside CreateSession is a session whose UserID a caller can rewrite. Copy the map (one level is enough) when storing a session and when returning one. Stores that reconstruct rows from a database read get this for free; in-memory ones do not. storetest.RunSessionStore checks it.
type Sulis ¶
type Sulis struct {
// contains filtered or unexported fields
}
Sulis is the main authentication service. It coordinates user registration, login, password reset, and session management.
func New ¶
func New(users UserStore, sessions SessionStore, tokens TokenStore, factors SecondFactorChecker, opts ...Option) (*Sulis, error)
New creates a new Sulis instance with the given stores and options.
factors is required and must not be nil: it is how the library learns that a user has a second factor, and defaulting it would mean silently issuing fully-privileged sessions to accounts that expect two-factor authentication. Applications with no second factors pass NoSecondFactors{}.
func (*Sulis) Authenticate ¶
Authenticate returns HTTP middleware that validates the session token from the channel(s) selected by the configured TokenSource (default TokenSourceBoth: either an Authorization: Bearer header or the configured session cookie — see WithTokenSource and WithCookieName). On success, the User and Session are attached to the request context and can be retrieved with UserFromContext and SessionFromContext. On failure, the middleware responds with 401 Unauthorized, carrying WWW-Authenticate and Cache-Control: no-store (see writeUnauthorized).
func (*Sulis) ChangeEmail ¶
ChangeEmail stages newEmail as the account's pending address and returns a raw, single-use token proving intent to claim it. The live Email and EmailVerifiedAt are untouched: they change only when the returned token is later redeemed via ConfirmEmailChange. Staging a second address before the first is confirmed supersedes it — the earlier token is invalidated (see ConfirmEmailChange).
Returns ErrInvalidEmail for a malformed address, and ErrUserAlreadyExists if newEmail is already the live address of any account, including this one — there is nothing to prove and nothing to change in that case.
The raw token is returned once for the caller to deliver to the NEW address; sulis does not send mail. Callers MUST also notify the OLD address that a change has been requested — that notification, sent to an address the attacker does not control, is how a victim catches an account takeover while the pending change can still be undone.
func (*Sulis) ChangePassword ¶
func (s *Sulis) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string, ri RequestInfo) error
ChangePassword changes a user's password after verifying the old password. The password policy — length, then the configured PasswordChecker — applies only to the new password; the old one was already validated when it was set, and re-judging it here would refuse the change to exactly the user who most needs to make it.
func (*Sulis) ClearSessionCookie ¶
ClearSessionCookie returns an *http.Cookie that, once set on the response with http.SetCookie(w, cookie), instructs the browser to delete the session cookie immediately: the same Name/Path/HttpOnly/Secure/ SameSite as SessionCookie, an empty Value, and both MaxAge=-1 and an Expires in the past — belt and suspenders, since not every HTTP client or intermediary proxy honors MaxAge.
func (*Sulis) CompleteTwoFactor ¶
func (s *Sulis) CompleteTwoFactor(ctx context.Context, userID, rawToken string, ri RequestInfo) (*LoginResult, error)
CompleteTwoFactor consumes a two-factor pending-login token issued by CreateTwoFactorToken and, once the app has independently verified the user's second factor, issues a new session. The token is single-use and purpose-scoped: it cannot be replayed, and it is rejected by any flow other than CompleteTwoFactor. Also returns ErrEmailNotVerified — as defense in depth, since the token is consumed either way — if the account's email is unverified and RequireVerifiedEmail is enabled (default); this checks the user's current state, not its state when the token was minted.
userID must be the ID the app obtained from its own VerifyPassword call and carried through its own server-side state (e.g. keyed by the pending token) — never a value supplied by the client on the second-factor request. The token is consumed first and then checked against userID, rejecting with ErrTokenInvalid on a mismatch; either way the token is burned, so a mismatched userID cannot be retried against the same token.
func (*Sulis) ConfirmEmailChange ¶
ConfirmEmailChange consumes a token issued by ChangeEmail. If the token still matches the account's currently staged address, it makes that address live: Email is swapped in from PendingEmail, PendingEmail is cleared, and EmailVerifiedAt is re-stamped with a fresh timestamp — the old stamp proved control of the old address, not this one. The swap also revokes every session on the account and purges its outstanding password-reset, two-factor, and magic-link tokens, since all three were minted against (or reachable through) the identity that just changed. The magic-link purge in particular is what makes this a recovery rather than a half-measure: a magic link requested while the attacker still had the mailbox is redeemed by user ID, with no check on the address it was sent to, so the swap alone would not stop it.
Returns ErrTokenInvalid if the token is unknown, expired, already used, of the wrong purpose, or bound to an address that is no longer the account's PendingEmail — the last case means a later ChangeEmail call has since superseded it, and the token for the abandoned address must not still be able to claim the account. Returns ErrUserAlreadyExists if another account has claimed the staged address since it was staged.
sulis does not send mail. Callers MUST notify the OLD address once this succeeds — that is how a victim whose address was just changed out from under them learns of it, even though the takeover has already completed.
func (*Sulis) CreateEmailVerificationToken ¶
CreateEmailVerificationToken generates a short-lived, single-use token proving control of the given user's registered email address. The token is bound to the user's current (normalized) email at issuance time, so it is invalidated by VerifyEmail if the address changes before redemption. The raw token is returned so the consumer can deliver it (e.g. via email).
func (*Sulis) CreateMagicLinkToken ¶
func (s *Sulis) CreateMagicLinkToken(ctx context.Context, email string, ri RequestInfo) (token, bindingNonce string, err error)
CreateMagicLinkToken generates a magic link token for the given email and, when magic-link binding is enabled (WithMagicLinkBinding, on by default), a companion binding nonce. If no user exists for the email, the token is issued without creating a user row — the user is created at redemption time (see RedeemMagicLink) so that requesting magic links for arbitrary addresses cannot be used to flood the user store before anything is ever delivered. The raw token is returned so the consumer can deliver it (e.g. via email).
The raw bindingNonce, when non-empty, must be set by the caller as a short-lived, HttpOnly cookie on the response to THIS request — never embedded in the emailed link itself — and read back from that cookie when the link is later clicked, to pass to RedeemMagicLink alongside the token recovered from the link. See WithMagicLinkBinding for the full wiring, the reasoning, and the empty-string case when binding is disabled.
func (*Sulis) CreatePasswordResetToken ¶
func (s *Sulis) CreatePasswordResetToken(ctx context.Context, email string, ri RequestInfo) (string, error)
CreatePasswordResetToken generates a password reset token for the given email and returns the raw token so the consumer can deliver it (e.g. via email).
If no account exists for email, it returns ("", nil) rather than ErrUserNotFound: this endpoint must not let a caller learn whether an address is registered. The unknown-user path still generates and hashes a token of the same size the known-user path would create — burning the same randomness and hashing work — before discarding it, so the two paths can't be told apart by the work they perform either. What can't be equalized is the store round trip: the known-user path writes a token row and the unknown-user path never does, since there is no user to attach one to. That residual asymmetry is the same kind VerifyPassword documents for its dummy-hash equalization above — perfect timing equality across a storage boundary isn't a claim this library can make.
Admin tooling that has already authenticated an operator and genuinely needs to know whether the address is registered should call CreatePasswordResetTokenStrict instead; it must never back a public-facing endpoint, or it reopens the user-enumeration oracle this method closes.
func (*Sulis) CreatePasswordResetTokenStrict ¶
func (s *Sulis) CreatePasswordResetTokenStrict(ctx context.Context, email string, ri RequestInfo) (string, error)
CreatePasswordResetTokenStrict behaves exactly like CreatePasswordResetToken except that it returns ErrUserNotFound verbatim for an unknown address instead of silently returning ("", nil). It exists for admin tooling that needs the truth about whether an address is registered; wiring it to a public-facing endpoint reintroduces the enumeration oracle CreatePasswordResetToken exists to close.
func (*Sulis) CreateTwoFactorToken ¶
CreateTwoFactorToken generates a short-lived, single-use pending-login token for a user who has passed the first authentication factor. Returns ErrEmailNotVerified if the account's email is unverified and RequireVerifiedEmail is enabled (default), failing before the app ever prompts for a second factor.
Intended app flow: VerifyPassword -> (app checks its own "user has 2FA" flag) -> CreateTwoFactorToken -> (app verifies the second factor: TOTP, recovery code, or passkey) -> CompleteTwoFactor. No session exists until CompleteTwoFactor succeeds.
func (*Sulis) DisableUser ¶
DisableUser marks userID as disabled, effective immediately: it stamps User.DisabledAt and records reason (caller-supplied context — sulis never inspects it, see User.DisabledReason), then revokes every existing session for the account.
The write that marks the account disabled happens BEFORE the session revocation, not after, and revocation is best treated as an optimization for immediate cutoff rather than the mechanism disabling actually depends on: even if DeleteUserSessions itself failed, every one of those sessions would still die on its very next use, because ValidateSession checks DisabledAt on every call. Without that check, disabling would leave live sessions working for the remainder of their natural lifetime — this is why ValidateSession's own check is the one piece of this feature that matters most.
Returns ErrUserNotFound if no such user exists.
func (*Sulis) EnableUser ¶
EnableUser reverses a previous DisableUser call: DisabledAt and DisabledReason are reset to their zero values, and authentication works again on the next attempt. It does not touch LockedUntil or FailedLoginAttempts — those belong to the separate automatic-lockout mechanism (see WithFailureLockout), and an operator re-enabling a manually disabled account is not the same event as a lockout window expiring; EnableUser should not silently forgive an in-progress lockout the operator may not even know about. It also does not restore any session DisableUser revoked — the account can simply start new ones.
Returns ErrUserNotFound if no such user exists.
func (*Sulis) IssueSession ¶
IssueSession creates a new session for the user identified by auth, which must come from completing a factor sulis itself verified. The zero value Authentication{} — and, since no exported constructor takes a bare user ID, any other Authentication not obtained from such a flow — is rejected with ErrNotAuthenticated before any store is touched.
Beyond that check, this behaves exactly like IssueSessionUnchecked: ErrUserNotFound if the proof's user no longer exists, and ErrEmailNotVerified if the account's email is unverified and RequireVerifiedEmail is enabled (default).
Applications authenticating by a factor sulis does not know how to verify itself — most notably a finished passkey ceremony, verified entirely by the passkey subpackage and the calling application — have no way to obtain an Authentication and must call IssueSessionUnchecked instead.
func (*Sulis) IssueSessionUnchecked ¶
func (s *Sulis) IssueSessionUnchecked(ctx context.Context, userID string, method AuthMethod) (*Session, string, error)
IssueSessionUnchecked creates a new session for userID without requiring an Authentication proof. It is IssueSession's old, unguarded behavior kept under a name that says so in code review: legitimate for a factor sulis does not know about — most commonly a finished passkey ceremony, which has no way to produce an Authentication — but calling it means the CALLER, not this package, is vouching that userID has completed every factor the application requires. sulis performs no credential check of its own here, only the same ErrUserNotFound / ErrEmailNotVerified gating IssueSession applies. method records which credential the caller is vouching for; sulis does not yet act on it beyond that, but capturing it keeps this method's contract symmetric with IssueSession's.
func (*Sulis) ListUserSessions ¶
ListUserSessions returns every session belonging to userID, most useful for a "where you're signed in" device-management screen: each entry carries CreatedAt, LastSeenAt, AuthenticatedAt, Method, IP, and UserAgent, enough for an application to render something like "Chrome, last active 2 hours ago" and let the user revoke anything they don't recognize via RevokeSession.
TokenHash is stripped to "" on every returned Session — this is the security property the task that added this method exists for. The store method behind this (SessionStore.ListUserSessions) returns TokenHash exactly as stored, the same as GetSessionByTokenHash; blanking it before it ever reaches a caller happens here, once, rather than depending on every current and future listing path remembering to do it themselves.
func (*Sulis) Login ¶
func (s *Sulis) Login(ctx context.Context, email, password string, ri RequestInfo) (*LoginResult, error)
Login authenticates a user with email and password.
A correct password is only the FIRST factor. If the configured SecondFactorChecker reports that the user has one enrolled, the returned LoginResult has NeedsSecondFactor set and carries a PendingToken instead of a session — no session exists until CompleteTwoFactor succeeds. Callers must branch on NeedsSecondFactor rather than assuming a non-nil result means the user is logged in.
Returns ErrInvalidCredentials if the email or password is wrong, and ErrEmailNotVerified if the account is unverified and RequireVerifiedEmail is enabled (the default).
func (*Sulis) ReAuthenticate ¶
func (s *Sulis) ReAuthenticate(ctx context.Context, session *Session, password string, ri RequestInfo) error
ReAuthenticate verifies password for the user who owns session and, on success, stamps session's AuthenticatedAt with the current time — both on the stored session and on the *Session the caller passed in, so neither a reload nor a fresh ValidateSession call is needed to observe the refresh. It mints no new session and does not rotate the session's token: the session's ID and TokenHash are exactly what they were before the call. This is the write side of the step-up gate RequireRecentAuth checks.
Like VerifyPassword, it is rate-limited on both the account dimension (key "password:"+email, the same budget Login/VerifyPassword/ ChangePassword share, since a stolen session token attempting to brute-force the password here is exactly the risk those guard) and the IP dimension, and it equalizes response timing for a passwordless account by running the same Argon2 work against an internal dummy hash rather than returning early. Returns ErrInvalidCredentials for a passwordless account or a wrong password — in neither case is AuthenticatedAt touched.
A successful verification here can also upgrade the stored hash, exactly like VerifyPassword's success path: if the hash is weaker than the currently configured Argon2Params, or predates NFKC normalization and matched only through verifyPassword's pre-normalization fallback, it is re-hashed with the plaintext just verified and written back, best-effort (see password.go's needsRehash and sulis.go's rehashPassword). This is deliberate, not an oversight left over from T504: ReAuthenticate is a real password comparison against a real stored hash, so it upgrades the same as any other one — see the T504 (fix round 1) Decisions row.
Also returns ErrAccountDisabled/ErrAccountLocked via accountStatus, checked right after loading the user and before spending an Argon2 verification on a call that cannot succeed either way. Unlike VerifyPassword's oracle-ordering concern (an unauthenticated caller must not learn account status without proving a password first), ReAuthenticate has no equivalent exposure to guard against: the caller already holds a valid *Session for this exact account — proof enough that the account exists — so checking status before the password costs nothing extra in exchange for not refreshing AuthenticatedAt on a disabled or locked account's already-held session. This closes the gap the T501 Decisions row deferred: see PROGRESS.md.
Concurrency caveat: on success, ReAuthenticate writes session.AuthenticatedAt directly on the *Session pointer the caller passed in, with no locking of its own around that write. That is exactly what lets the caller observe the refresh without a reload (see above), but it also means an application that shares one *Session across goroutines — caching it per user, say, rather than fetching a fresh one from ValidateSession per request — is responsible for synchronizing its own reads and writes of that pointer. ReAuthenticate does not, and cannot, do that synchronization on the application's behalf.
func (*Sulis) RedeemMagicLink ¶
func (s *Sulis) RedeemMagicLink(ctx context.Context, rawToken, bindingNonce string, ri RequestInfo) (*LoginResult, error)
RedeemMagicLink validates a magic link token and, when the token carries a stored NonceHash (magic-link binding was enabled at issuance — see WithMagicLinkBinding, on by default), the bindingNonce that must accompany it. If the token was issued before the user existed, the user is created now, as a passwordless account.
bindingNonce must equal the raw nonce CreateMagicLinkToken returned alongside this same rawToken (compared via its SHA-256 hash, in constant time via crypto/subtle.ConstantTimeCompare) — typically recovered from the short-lived HttpOnly cookie the application set at issuance time; see WithMagicLinkBinding for the full wiring and reasoning. A missing or wrong bindingNonce is rejected with ErrTokenInvalid, exactly like a missing or wrong token, so neither leaks which half was the problem. When the token carries no NonceHash — binding was disabled when it was issued — any bindingNonce is accepted, including "".
The binding check runs AFTER the token is consumed (see consumeToken), primarily because consumeToken's atomicity contract — one indivisible find-and-mark-used operation — has no room for a nonce check in the middle of it without either breaking that atomicity (a check-first design would need to read the row, check the nonce, and mark it used as three separate steps, reopening exactly the TOCTOU consumeToken's single operation exists to close) or widening TokenStore.ConsumeToken to accept and verify a nonce hash itself, a larger interface change this task does not make. A secondary effect of the ordering, the same fail-safe direction expiry is already checked in: a wrong nonce still burns the token, so an attacker who obtains a token but not its nonce gets exactly one attempt rather than unlimited retries against a token that stays live — though with a 128-bit nonce, guessing was never the realistic threat this closes; the atomicity constraint is.
A magic link is a FULL first factor — proving control of the mailbox is equivalent to knowing the password — so it is gated by two-factor authentication exactly like Login. If the account has a second factor enrolled, the returned LoginResult carries a PendingToken rather than a session. Without this, anyone able to read the mailbox would bypass 2FA entirely, which is precisely the attacker a second factor exists to stop.
func (*Sulis) RefreshSession ¶
RefreshSession rotates session's token: it retires session's old row first and, only if that succeeds, mints a new session row with a new ID and a new raw token, extending ExpiresAt from now while carrying UserID, Method, AuthenticatedAt, CreatedAt, IP, UserAgent, and Metadata forward unchanged.
AuthenticatedAt is preserved deliberately: a refresh is a liveness/ rotation operation, not a fresh authentication proof, and must not reset the step-up clock RequireRecentAuth reads.
The returned *Session has a different ID and TokenHash than session — this is a new store row, not an in-place update to the one passed in. Deliberate: SessionStore has no primitive to rewrite a session's token and expiry in place (TouchSession and UpdateAuthenticatedAt each update a narrow, different pair of columns), so building a fresh row from the existing CreateSession/DeleteSession pair avoids adding a third narrow-purpose update method to the store contract for the sake of one caller. Rotating the ID is also a small defense-in-depth win: a previously-leaked session ID stops referring to anything live the moment this call succeeds.
The OLD row is deleted FIRST, and RefreshSession only proceeds to mint a new one if that delete actually succeeds — this is a fail-closed liveness check, not an optimization. Without it, a caller holding a stale *Session obtained before a revocation (RevokeSession, RevokeAllSessions, or a device evicted through the ListUserSessions screen this package builds) could call RefreshSession and mint a brand-new working session anyway, un-evicting themselves: CreateSession never consults whether the old row still exists, so a create-then-delete order with the delete's result discarded lets exactly that happen. DeleteSession returning ErrSessionNotFound (the old row is already gone) is therefore propagated verbatim, before any new row is created. This is the same "burn first, validate second" direction consumeToken and passkey's ConsumeChallenge already take ("failures burn the token") and the reason DeleteSession's own ownership-scoped delete-with-error-on-zero-rows exists in the first place: the cost is a crash window between the delete and the create logging the caller out, which is the safe direction to fail in, not an account left refreshable after it should not be.
For the same reason, this reloads the user and checks accountStatus before minting, closing the one remaining way a stale *Session could still refresh into a live one: DisableUser's own session revocation could legitimately fail (store error) while its DisabledAt stamp still lands, leaving the old row intact for DeleteSession to happily remove above — without this check, that would be enough to mint a fresh session for a disabled account, since a newly-minted row never passes back through ValidateSession's own DisabledAt gate. Both checks run after the delete succeeds, so a disabled-account refresh still burns the caller's old session on its way to ErrAccountDisabled, consistent with the fail-closed direction above.
The reloaded user is then held to RequireVerifiedEmail (default true) as well, returning ErrEmailNotVerified — a refresh mints a session, and every other minting path applies that gate. Register's signup session is the one deliberate exemption, so that a new user can hold a session long enough to click the verification link; without this check that exemption never expired, because the signup session could be rotated indefinitely and an account that never verified would keep a live session forever. The same delete-first ordering applies here too: a refresh refused for an unverified account still costs the caller their old session, exactly as the disabled-account case does. That is the intended trade — the caller verifies their address and signs in again, and the alternative (mint first, gate after) is the failure mode this whole ordering exists to prevent. Pass WithRequireVerifiedEmail(false) to restore unconditional rotation.
RefreshSession takes no RequestInfo — Appendix A gives it none — so IP and UserAgent are carried forward from the caller's (possibly stale) in-memory session rather than re-derived from the current request. A long-lived session refreshed repeatedly from a new IP can therefore show a stale IP/UserAgent in a "where you're signed in" listing even while LastSeenAt looks current; see the PROGRESS.md Decisions row.
func (*Sulis) Register ¶
func (s *Sulis) Register(ctx context.Context, email, password string, ri RequestInfo) (*User, *Session, string, error)
Register creates a new user with the given email and password, and returns a new session. Returns ErrUserAlreadyExists if the email is already taken.
func (*Sulis) RequireCSRFToken ¶
RequireCSRFToken is the package-level RequireCSRFToken bound to this Sulis, so a rejection reaches the configured EventSink as EventCSRFRejected. The check itself is identical — same VerifyCSRFToken, same 403, same Cache-Control — and either form may be used; this one is simply the one that can report.
A method and a package-level function of the same name is deliberate rather than a rename: RequireCSRFToken is already-shipped public API, and emitting requires state (the sink) that a free function has no way to reach. See the T509 Decisions row in PROGRESS.md.
func (*Sulis) RequireRecentAuth ¶
func (s *Sulis) RequireRecentAuth(ctx context.Context, session *Session, maxAge time.Duration) error
RequireRecentAuth returns ErrReauthRequired if session's AuthenticatedAt is older than maxAge, and nil otherwise. It does not touch any store: it is a pure check against the *Session the caller already holds (typically the one ValidateSession just returned), so gating an endpoint with it costs no extra round trip.
A session issued before this field existed, or otherwise never stamped, reads back with the zero time for AuthenticatedAt. time.Since of the zero time is on the order of two thousand years, which is older than any realistic maxAge, so such a session always fails this check — fail closed, not "treat an absent stamp as fresh."
Gate security-relevant account changes behind this rather than a bare session: enrolling or replacing a TOTP factor (totp.Service.Enroll, ReplaceEnrollment), adding or removing a passkey, disabling two-factor authentication, changing email (ChangeEmail), and regenerating recovery codes should all require proving the credential again, not merely holding a cookie from hours ago. See the README's "Step-up authentication" section for the full list and example wiring.
func (*Sulis) RequireSameOrigin ¶
RequireSameOrigin is the package-level RequireSameOrigin bound to this Sulis, so a rejection reaches the configured EventSink as EventSameOriginRejected — carrying ReasonCrossSite or ReasonOriginNotAllowed, which of the two checks refused. The policy is identical in every other respect; see the package-level function's doc comment for it, and the T509 Decisions row in PROGRESS.md for why this is a same-named method rather than a rename.
func (*Sulis) ResetPassword ¶
ResetPassword resets a user's password using a raw reset token. The password policy is checked before the token is consumed, so a policy failure does not burn the token.
func (*Sulis) RevokeAllSessions ¶
RevokeAllSessions deletes all sessions for a user.
func (*Sulis) RevokeSession ¶
RevokeSession deletes a single session belonging to userID. It returns ErrSessionNotFound if sessionID does not exist or belongs to a different user, so a caller can only ever revoke their own sessions — guessing or leaking another user's session ID cannot be used to end their session.
func (*Sulis) SessionCookie ¶
SessionCookie returns an *http.Cookie carrying rawToken as its value, ready to be set on the response with http.SetCookie(w, cookie). Every attribute a secure session cookie needs is fixed here, not left to the caller:
- HttpOnly: never readable by JavaScript, closing off the most common session-theft vector (script injection reading document.cookie).
- Secure: never sent over plain HTTP.
- SameSite=Lax: sent on top-level, safe-method navigation and same-site requests; withheld from cross-site subrequests and cross-site state-changing navigation, which is most of CSRF exposure with no extra work. It is NOT a substitute for RequireSameOrigin/ RequireCSRFToken — see the README's "Cookie sessions and CSRF" section for what SameSite alone does and doesn't cover.
- Path=/: the cookie is valid for the whole origin, matching the __Host- prefix requirement below.
The cookie's Name is CookieName (default: "__Host-session", see WithCookieName), and the __Host- prefix's other two requirements — Secure and no Domain attribute — are exactly what this method always sets/omits, regardless of name, so the guarantee never silently stops applying. See defaultCookieName's doc comment for why this is enforced by construction rather than by validating the combination at runtime.
func (*Sulis) SetInitialPassword ¶
SetInitialPassword sets the first password for a passwordless user.
func (*Sulis) ValidateSession ¶
ValidateSession validates a session token and returns the session and user. Returns ErrSessionNotFound or ErrSessionExpired on failure.
A session past its idle deadline (IdleExpiresAt, set only when WithIdleTimeout is configured) is rejected the same way as one past its absolute ExpiresAt — checked first, since idle expiry exists to end a session well before its absolute lifetime in the common case, and either way the outcome (ErrSessionExpired, the row deleted) is identical.
On success, LastSeenAt/IdleExpiresAt are refreshed via TouchSession, but only when the session's current LastSeenAt is already older than sessionTouchInterval — see that constant's doc comment (session.go) for why this is throttled rather than written on every call. The touch is best effort: a failed write does not fail validation, since the session itself is still valid regardless of whether its liveness bookkeeping happens to update this time.
func (*Sulis) VerifyEmail ¶
VerifyEmail consumes an email-verification token issued by CreateEmailVerificationToken and stamps the user's EmailVerifiedAt. The token is single-use and purpose-scoped: it cannot be replayed, and it is rejected by any flow other than VerifyEmail. It is also rejected with ErrTokenInvalid if the user's email has changed since the token was issued, so a verification token can never prove control of an address the user no longer holds.
func (*Sulis) VerifyPassword ¶
func (s *Sulis) VerifyPassword(ctx context.Context, email, password string, ri RequestInfo) (*User, error)
VerifyPassword checks an email and password against the stored credentials without creating a session. Returns ErrInvalidCredentials if the email or password is wrong. Like Login, it equalizes response timing for unknown-user and passwordless-user cases by running the same Argon2 work against a dummy hash.
One further timing note, narrow enough to rarely matter: a successful verification against a hash written before NFKC normalization existed (see the README's "Upgrading" section) costs a second Argon2 comparison — one for an account that has already migrated, two for one that hasn't logged in since. The gap closes for good after that account's next successful login, and it only exists at all for a password containing characters an already-normalized (e.g. plain ASCII) password never has. See verifyPassword's doc comment (password.go) for the full accounting, including why this is not a guessing oracle.
type Token ¶
type Token struct {
ID string
UserID string
TokenHash string // SHA-256 hash of the raw token; raw token is never stored
Purpose TokenPurpose
ExpiresAt time.Time
CreatedAt time.Time
Used bool
// Email records the address a token proves control of. It is set for
// magic-link tokens issued before the user account exists (UserID is
// empty in that case until the token is redeemed) and for
// email-verification tokens (bound to the user's email at issuance, so a
// later address change invalidates an outstanding token). It is empty
// for password-reset and two-factor tokens.
Email string
// NonceHash is the SHA-256 hash of a magic-link binding nonce (see
// WithMagicLinkBinding) — the raw nonce is never stored, matching
// TokenHash's own treatment of the raw token. It is set only for a
// magic-link token issued while binding was enabled (the default);
// empty for every other token purpose, and empty for a magic-link
// token issued while binding was disabled — RedeemMagicLink accepts
// any bindingNonce, including "", whenever NonceHash is empty.
NonceHash string
}
Token represents a single-use, time-limited token for password resets or magic links.
type TokenPurpose ¶
type TokenPurpose string
TokenPurpose identifies the intended use of a token.
const ( TokenPurposePasswordReset TokenPurpose = "password_reset" TokenPurposeMagicLink TokenPurpose = "magic_link" TokenPurposeTwoFactor TokenPurpose = "two_factor" TokenPurposeEmailVerification TokenPurpose = "email_verification" // #nosec G101 -- a purpose label, not a credential TokenPurposeEmailChange TokenPurpose = "email_change" // #nosec G101 -- a purpose label, not a credential )
type TokenSource ¶
type TokenSource int
TokenSource controls which channel(s) Authenticate accepts a session token from. See WithTokenSource.
const ( // TokenSourceBoth accepts either an Authorization: Bearer header or the // configured session cookie — today's behavior, and the default. // // This stays the default even though this package now ships cookie // support (SessionCookie) and CSRF defenses (RequireSameOrigin, // RequireCSRFToken) in the same task that introduced this type: a // Bearer header is never attached to a request automatically by a // browser, so accepting one alongside a cookie does not create or // widen a CSRF exposure by itself — that exposure comes entirely from // the cookie channel, and is exactly what RequireSameOrigin/ // RequireCSRFToken exist to close. Narrowing the default to // TokenSourceCookieOnly would break every existing Bearer-only // consumer for no CSRF benefit, since Bearer was never the risk. // See the T507 Decisions row in PROGRESS.md. TokenSourceBoth TokenSource = iota // TokenSourceCookieOnly rejects an Authorization: Bearer header // entirely — Authenticate never even reads it — and honors only the // configured session cookie. TokenSourceCookieOnly // TokenSourceBearerOnly rejects the session cookie entirely — // Authenticate never even reads it — and honors only an Authorization: // Bearer header. A deployment that sets this, and never calls // SessionCookie, needs neither RequireSameOrigin nor the CSRF helpers: // without a cookie there is no ambient credential for a forged // cross-site request to ride on. TokenSourceBearerOnly )
type TokenStore ¶
type TokenStore interface {
CreateToken(ctx context.Context, token *Token) error
// ConsumeToken atomically finds the unused token matching hash AND purpose
// and marks it used, returning it. Lookup and mark MUST be one atomic
// operation (e.g. UPDATE ... WHERE hash=? AND purpose=? AND used=false).
// Returns ErrTokenNotFound if no token matches hash+purpose;
// ErrTokenAlreadyUsed if it exists but was already consumed.
ConsumeToken(ctx context.Context, hash string, purpose TokenPurpose) (*Token, error)
DeleteExpiredTokens(ctx context.Context) error
// DeleteUserTokens deletes all tokens for the given user and purpose.
// Deleting zero tokens is not an error.
DeleteUserTokens(ctx context.Context, userID string, purpose TokenPurpose) error
}
TokenStore defines the persistence operations for tokens.
type User ¶
type User struct {
ID string
Email string
PasswordHash string // empty for passwordless-only users
CreatedAt time.Time
UpdatedAt time.Time
Metadata map[string]any
// EmailVerifiedAt records when the user's email address was confirmed as
// reachable (e.g. via VerifyEmail or a redeemed magic link). Nil means
// the address has not been verified.
EmailVerifiedAt *time.Time
// PendingEmail holds a staged address awaiting proof of control, set by
// ChangeEmail. The live Email field never changes except through a
// successful ConfirmEmailChange, which also clears this back to empty.
PendingEmail string
// DisabledAt records when DisableUser took the account out of service.
// Nil means the account is active. VerifyPassword's post-verification
// check, completeFirstFactor, issueSessionForUser, and CompleteTwoFactor
// all reject with ErrAccountDisabled while it is set, and ValidateSession
// rejects an already-issued session the same way — so disabling an
// account invalidates every session already issued, not merely future
// logins. Cleared only by EnableUser.
DisabledAt *time.Time
// DisabledReason is caller-supplied context recorded by DisableUser
// (e.g. "reported for abuse", "closed by support"). sulis never inspects
// it. EnableUser clears it back to empty alongside DisabledAt.
DisabledReason string
// LockedUntil records the end of a temporary authentication lockout.
// Nil, or a time already in the past, means the account authenticates
// normally. It is set only by the optional automatic-lockout mechanism
// (see WithFailureLockout) after repeated wrong passwords; the same
// post-verification checks that reject ErrAccountDisabled also reject
// ErrAccountLocked while this is still in the future. It is cleared
// (along with FailedLoginAttempts) the next time a correct password
// verifies outside the window, or the account's password is
// successfully changed or reset (ChangePassword, ResetPassword,
// SetInitialPassword) — there is no explicit unlock call for either.
// Unlike DisabledAt, an active lock does not invalidate sessions already
// issued: ValidateSession does not check it, only new authentication
// does (see the README's "Account disable and lockout" section for why);
// and unlike DisabledAt, a password reset/change DOES clear it — proving
// control of the account well enough to set a new password is at least
// as strong an identity proof as the login password itself, whereas
// DisabledAt records an operator's decision that no proof of the
// password reverses.
LockedUntil *time.Time
// FailedLoginAttempts counts consecutive wrong passwords since the last
// correct one. It only ever advances when WithFailureLockout is
// configured, and is reset to 0 whenever a correct password verifies
// outside an active lockout window, or the account's password is
// successfully changed or reset.
FailedLoginAttempts int
// Version guards against lost updates. It is set by the store on read and
// must be passed back unchanged in UpdateUser, which applies the write
// only if it still matches the persisted row. Callers outside the store
// never set it themselves.
Version uint64
}
User represents an authenticated user.
type UserStore ¶
type UserStore interface {
// CreateUser persists a new user. Returns ErrUserAlreadyExists if user.Email
// is already the live address of another user.
CreateUser(ctx context.Context, user *User) error
GetUserByID(ctx context.Context, id string) (*User, error)
GetUserByEmail(ctx context.Context, email string) (*User, error)
// UpdateUser persists user, but ONLY if the stored row's version still
// equals user.Version. On success the stored version MUST be incremented;
// on mismatch the write MUST be discarded and ErrConcurrentUpdate
// returned. Without this, two flows that each read-modify-write the whole
// row can clobber each other — and the dangerous direction restores a
// password hash the user just rotated away from.
//
// UPDATE users SET ..., version = version + 1
// WHERE id = $1 AND version = $2
//
// Zero rows affected means another writer won: return ErrConcurrentUpdate.
//
// UpdateUser MUST also return ErrUserAlreadyExists if user.Email would
// collide with a different user's live email — e.g. two accounts racing
// to confirm a change to the same staged address. This is the real
// guarantee behind that race, not the in-library pre-check described
// above.
UpdateUser(ctx context.Context, user *User) error
DeleteUser(ctx context.Context, id string) error
}
UserStore defines the persistence operations for users. Consumers implement this interface for their own database.
A store MUST NOT share mutable state with its callers in either direction. Metadata is a map and EmailVerifiedAt, DisabledAt, and LockedUntil are each a pointer, so copying a *User with a plain struct assignment copies a map header and an address, not the map and not the time — leaving the caller holding a live handle on the stored row. That is a way to rewrite a persisted user without going through UpdateUser at all, which defeats the Version precondition below by simply stepping around it. Copy the map (one level is enough; values inside it are the caller's business) and each pointed-to time when storing a user and when returning one. Stores that reconstruct rows from a database read get this for free; in-memory ones do not. storetest.RunUserStore checks it.
Email uniqueness MUST be enforced at the storage layer — e.g. a SQL UNIQUE index on the normalized email column — and CreateUser and UpdateUser MUST return ErrUserAlreadyExists when a write would violate it. This is not optional. Version (below) only guards a lost update on a single row; it says nothing about two different rows racing to claim the same address (e.g. two accounts both confirming a staged change to the same address). Nothing above this interface can make those two writes atomic with respect to each other, since by the time either call reaches UserStore they are independent reads and writes on different rows. A caller may re-check uniqueness with GetUserByEmail before writing (ConfirmEmailChange does), but that is only a best-effort early rejection, not the guarantee: two callers can both pass that check for the same address before either write lands. The store's write path enforcing the constraint is what actually closes the race.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore is the reference in-memory implementation of every store interface sulis defines.
|
Package memstore is the reference in-memory implementation of every store interface sulis defines. |
|
Package passkey implements WebAuthn-based passkey registration and authentication.
|
Package passkey implements WebAuthn-based passkey registration and authentication. |
|
Package passwordcheck screens passwords against known-compromised values.
|
Package passwordcheck screens passwords against known-compromised values. |
|
Package recovery implements one-time recovery codes as a fallback for two-factor authentication: when a user loses their TOTP device or passkey, a recovery code lets them regain access without a support-driven bypass.
|
Package recovery implements one-time recovery codes as a fallback for two-factor authentication: when a user loses their TOTP device or passkey, a recovery code lets them regain access without a support-driven bypass. |
|
store
|
|
|
sql
module
|
|
|
Package storetest is the conformance suite for the persistence interfaces sulis defines.
|
Package storetest is the conformance suite for the persistence interfaces sulis defines. |
|
Package totp implements TOTP (RFC 6238) with zero external dependencies.
|
Package totp implements TOTP (RFC 6238) with zero external dependencies. |