security

package
v0.25.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package security holds the authorization primitives of the framework.

It is not an optional package: Grant, Policy, sessions and password hashing live in the core because security is the product thesis, not a plugin the user may forget to install.

Index

Constants

View Source
const (
	// MaxSignInFailures is how many wrong passwords one address may offer for
	// one identity inside SignInWindow.
	//
	// Five is chosen from the human side of the form. Somebody who is not sure
	// which of their three passwords this application knows needs three or four
	// tries; nobody needs a sixth inside the same minute. On the other side, it
	// turns an online guessing run against one account into five guesses a
	// minute, which is not an attack -- it is a rounding error against any
	// password worth the name.
	MaxSignInFailures = 5

	// MaxSignInFailuresPerClient is the budget of one address across every
	// identity it names, and it is what makes the constant above mean anything.
	//
	// Keyed only by identity and address, a leaked address list is still five
	// free guesses per account and the whole list gets walked. Worse, every
	// address typed opens a counter, so a script naming a million of them is a
	// million live entries -- the counter meant to stop an attack becomes the
	// way to exhaust the process.
	//
	// Five accounts' worth, and it is a cap on *this address*, so it is only as
	// narrow as RemoteAddr is specific. Behind a proxy that does not rewrite
	// RemoteAddr every request in the world shares one budget, and then this
	// constant is a cap on the whole application's sign-in form -- twenty-five
	// wrong passwords a minute across every customer. That deployment has to be
	// fixed at the proxy; see KeyByIP, which says the same thing from the other
	// end.
	MaxSignInFailuresPerClient = 5 * MaxSignInFailures

	// SignInWindow is how long a spent budget stays spent. A minute, because
	// the point is to make guessing slow rather than to punish: the person who
	// mistyped their password five times gets in a minute later without writing
	// to support.
	//
	// It is a fixed window and not a sliding one, so the honest number is up to
	// ten guesses across a boundary -- five in the last instant of one window
	// and five in the first instant of the next -- and five a minute after that.
	// The same arithmetic as MemoryLimiter, kept deliberately: a second, cleverer
	// notion of "window" in the same framework is what RULE 9 refuses, and
	// doubling five is not what makes an online guessing run work.
	SignInWindow = time.Minute
)

The sign-in policy. It is three constants and not configuration: a lockout somebody can widen from the environment is a lockout somebody widens the morning it fires, and then nobody narrows it again.

View Source
const IntendedCookieName = "arandu_intended"

IntendedCookieName carries the address somebody was going to, between the request a guard turned away and the sign-in that follows it.

Fixed, for the reason SessionCookieName is fixed: the guard writes it and the sign-in handler reads it, and two parts of a project that disagree about the name is a person who signs in and lands on the front page with no explanation -- which is the thing this exists to remove.

It is a cookie and not a row: there is no session yet at the moment the guard fires, which is exactly why it fires, so there is nowhere on the server to put it that is keyed to this browser.

View Source
const IntendedLifetime = 10 * time.Minute

IntendedLifetime is how long the address is worth keeping.

Long enough to find a password in a manager and type it, and short enough that it does not survive to an unrelated sign-in: on a shared machine, an address kept for a day sends the next person who signs in to the page the previous one was refused.

View Source
const (

	// MinPasswordLen is the shortest password the framework accepts. Length
	// beats composition rules: it is the only parameter that reliably raises
	// the cost of an offline attack.
	MinPasswordLen = 12
)

argon2id parameters. They are deliberately not configurable through the environment: an insecure hash configuration is the most common way to break authentication without noticing.

View Source
const PasswordConfirmationWindow = 3 * time.Hour

PasswordConfirmationWindow is how long typing the password again counts for.

Three hours, which is what Laravel's auth.password_timeout has been since 6.x, and a constant for the reason MaxSignInFailures is one: a window somebody can widen from the environment is a window somebody widens the afternoon it is inconvenient, and nobody narrows it again.

The number is chosen from both ends. Long enough that somebody spending an afternoon in the sensitive part of an application types their password once rather than at every step, because a check people route around is not a check. Short enough that a machine left unlocked overnight asks whoever sits down at it in the morning -- which is the situation the confirmation exists for, and the one a session lifetime alone never catches.

It is read by middleware.RequireConfirmedPassword and by anything else asking Subject.PasswordConfirmedWithin, so the whole application agrees on one answer to "recently".

View Source
const RememberLifetime = 30 * 24 * time.Hour

RememberLifetime is how long a session started with Remember(true) lives.

Longer than a working session, and deliberately not unlimited. The cookie is a bearer credential sitting on a device that gets shared, lost, resold and borrowed, so "stay signed in" has to end on its own: Laravel's remember cookie lasts five years, which means a laptop sold in year two still opens the account. A month is long enough for the box to be worth ticking -- that is the whole point of it -- and short enough that a device which left the person's hands stops working inside a billing cycle, where somebody notices.

A store configured with a longer ttl than this keeps its own: see SessionStore.lifetime. Remember must never make a session shorter.

View Source
const SessionCookieName = "arandu_session"

SessionCookieName is the cookie the framework reads and writes. It is fixed on purpose: a configurable cookie name buys nothing and breaks the CSRF binding when two parts of a project disagree about it.

Variables

View Source
var (
	// ErrNoSession means the request carries no session cookie, or the cookie
	// signature does not match the application key.
	ErrNoSession = errors.New("arandu: no session")
	// ErrSessionExpired means the session id is well formed but the backend no
	// longer holds it -- expired, or destroyed by a logout elsewhere.
	ErrSessionExpired = errors.New("arandu: session expired")
	// ErrConfirmationNotStored means the backend accepted the password
	// confirmation stamp and did not keep it, so no window would ever be
	// satisfied and the password screen would ask again immediately.
	//
	// It is a defect in the backend, not in the request: the fix is to carry
	// Subject.PasswordConfirmedAt in whatever shape that backend stores. A
	// handler that receives it must report a failure rather than redirect,
	// because redirecting is the loop.
	ErrConfirmationNotStored = errors.New("arandu: the session backend did not keep the password confirmation stamp")
)

Errors returned by SessionStore.

View Source
var ErrCSRF = errors.New("arandu: invalid or expired CSRF token")

ErrCSRF means the token is invalid, expired, or bound to another session.

View Source
var ErrExpired = fmt.Errorf("%w: the link has expired", ErrSignature)

ErrExpired is a valid signature that has run out of time. It unwraps to ErrSignature, so a caller that does not care about the difference does not have to check for both -- and one that does can offer a new link.

View Source
var ErrForbidden = errors.New("arandu: action not authorized")

ErrForbidden is the only authorization error. Handlers translate it to 403.

View Source
var ErrInvalidPassword = errors.New("arandu: invalid password")

ErrInvalidPassword means the password does not match the stored hash.

View Source
var ErrSignature = errors.New("security: the signature is not valid")

ErrSignature is what every failure below unwraps to, so a caller answers "this link is not valid" once rather than switching on four reasons it is not.

Functions

func HashPassword

func HashPassword(plain string) (string, error)

HashPassword returns the hash in PHC string format, which is self-describing and therefore allows rehashing when parameters change.

func NeedsRehash

func NeedsRehash(encoded string) bool

NeedsRehash reports that the hash was produced with older parameters, so the caller should rehash on the next successful login.

func ValidTenant added in v0.10.0

func ValidTenant(tenant string) bool

ValidTenant reports whether a tenant identifier is safe to use as a namespace.

Exported because the adapters build keys from it and a second, slightly different definition in each of them is how one of them ends up permissive.

func VerifyPassword

func VerifyPassword(plain, encoded string) error

VerifyPassword compares in constant time.

Types

type Action

type Action string

Action is the intended operation, in "module.verb" form.

type CSRF

type CSRF struct {
	// contains filtered or unexported fields
}

CSRF issues double-submit tokens signed with HMAC and bound to the session. It keeps no server-side state: the token carries its own expiry, so a deployment does not need Redis just to protect forms.

func NewCSRF

func NewCSRF(appKey []byte, ttl time.Duration) *CSRF

NewCSRF returns a token issuer keyed by the application key.

func (*CSRF) Issue

func (c *CSRF) Issue(sessionID string) (string, error)

Issue generates a token for a session.

func (*CSRF) Validate

func (c *CSRF) Validate(sessionID, token string) error

Validate checks signature, expiry and the binding to the session.

type Grant

type Grant struct {
	// contains filtered or unexported fields
}

Grant is the proof that an authorization decision happened.

THIS IS THE CENTRAL PIECE OF THE FRAMEWORK. Grant has only unexported fields, so it cannot be built by writing a struct literal: every repository signature requires one, and reaching the database without a Grant does not compile.

What the compiler does NOT decide is which Grant. Authorize is the mandatory path and the only one where a Policy answered; SystemGrant is the named escape hatch and jobs.GrantFor wraps it, and both are exported, so a handler can construct a Grant nobody authorized. What stops that is `aru doctor` -- a lint, not the type system -- with system-grant-outside-scope, system-grant-without-tenant and tenant-from-request.

This comment used to say "no public constructor other than Authorize", which was never true and read as a compile-time guarantee for something a lint enforces. It is the difference between the promise and the mechanism, and stating it wrong here is worse than anywhere else: this is the doc a reader checks the thesis against.

The alternative shape -- authorization as a call the handler remembers to make -- is authorization that gets forgotten, and nothing warns you.

func Authorize

func Authorize[T any](ctx context.Context, p Policy[T], s Subject, a Action, resource T) (Grant, error)

Authorize runs the policy and, when allowed, issues the Grant.

func SystemGrant

func SystemGrant(a Action, tenant string) Grant

func (Grant) Action

func (g Grant) Action() Action

Action exposes what was authorized.

func (Grant) Check

func (g Grant) Check(expected Action) error

Check is the guard every repository operation must call.

It fails on the zero value -- the only Grant a caller outside this package can build -- and when the grant was issued for a different action, which catches copy-paste between repository methods.

func (Grant) Subject

func (g Grant) Subject() Subject

Subject exposes who was authorized -- used to scope SQL by tenant.

type MemoryBackend

type MemoryBackend struct {
	// contains filtered or unexported fields
}

MemoryBackend keeps sessions in the process memory.

It is the right choice for development and for a single instance. Behind more than one pod it silently logs people out on every deploy and on every request routed elsewhere -- use the redis adapter there.

func NewMemoryBackend

func NewMemoryBackend() *MemoryBackend

NewMemoryBackend returns an empty in-memory session backend.

func (*MemoryBackend) Delete

func (m *MemoryBackend) Delete(ctx context.Context, id string) error

Delete removes the session, if present.

func (*MemoryBackend) DeleteSubject added in v0.25.0

func (m *MemoryBackend) DeleteSubject(ctx context.Context, tenant, subjectID, keepID string) error

DeleteSubject removes every session of one subject of one tenant except keepID.

It is a scan of the map, and it stays a scan: a second map keyed by subject would have to be kept in step with expiry, with Delete and with eviction, and getting that wrong leaves a password reset believing it signed somebody out. This backend holds one instance's sessions, and walking them costs less than the round trip the caller just made. A distributed backend cannot scan and carries a real index -- see the kv adapter.

func (*MemoryBackend) Get

func (m *MemoryBackend) Get(ctx context.Context, id string) (Subject, error)

Get returns the subject, or ErrSessionExpired when the id is unknown.

func (*MemoryBackend) Put

func (m *MemoryBackend) Put(ctx context.Context, id string, s Subject, ttl time.Duration) error

Put stores the subject under id for the given ttl.

type MemoryThrottle added in v0.25.0

type MemoryThrottle struct {
	// contains filtered or unexported fields
}

MemoryThrottle is the sign-in throttle in process memory.

It is right for development and for a single instance. Behind more than one pod the budget multiplies by the number of pods, the same way the in-memory limiter's window does -- use the kv-backed implementation there.

It has no background goroutine. Everything it removes, it removes on the call that would have grown it, because a sweeper started by a constructor outlives every test that builds one and keeps the whole table alive with it.

func NewMemoryThrottle added in v0.25.0

func NewMemoryThrottle() *MemoryThrottle

NewMemoryThrottle returns an empty in-memory sign-in throttle.

func (*MemoryThrottle) Attempt added in v0.25.0

func (m *MemoryThrottle) Attempt(tenant, identity, client string) (time.Duration, bool)

Attempt takes one unit from the pair's budget and one from the address's, or refuses.

Both budgets are read before either is written, and all of it happens under one lock. That is not tidiness: deciding and recording in two steps is what let a burst of simultaneous guesses each see an empty counter, and the lock is what makes the decision and the record the same event.

func (*MemoryThrottle) Clear added in v0.25.0

func (m *MemoryThrottle) Clear(tenant, identity, client string)

Clear forgets the pair's failures and refunds the address for this attempt.

func (*MemoryThrottle) Len added in v0.25.0

func (m *MemoryThrottle) Len() int

Len reports how many counters are held. It exists so a test can prove the table is bounded, and it mirrors MemoryLimiter.Len for the same reason.

func (*MemoryThrottle) Refund added in v0.25.0

func (m *MemoryThrottle) Refund(tenant, identity, client string)

Refund gives one unit back to each of the two budgets.

type Policy

type Policy[T any] interface {
	// Can decides whether subject may perform action on resource. resource may
	// be the zero value for collection actions (e.g. "customer.list").
	Can(ctx context.Context, s Subject, a Action, resource T) error
}

Policy decides. One policy per entity, always in the module's <entity>.policy.go file -- the CLI generates the skeleton and `aru doctor` complains when a repository exists without a matching policy.

type SessionBackend

type SessionBackend interface {
	// Get returns the subject, or ErrSessionExpired when the backend does not
	// hold the id -- expired, evicted, or destroyed by a logout elsewhere.
	//
	// ErrSessionExpired specifically, not the backend's own not-found error.
	// Callers branch on it to send somebody back to the login page, and a
	// backend that returns something else makes swapping the store change the
	// behaviour of the application. Found by audit: the kv backend returned its
	// own kv.ErrNotFound, so an expired session in Redis fell through to the
	// generic error path that a single-instance deployment never reached.
	Get(ctx context.Context, id string) (Subject, error)
	Put(ctx context.Context, id string, s Subject, ttl time.Duration) error
	Delete(ctx context.Context, id string) error

	// DeleteSubject removes every session belonging to one subject of one tenant,
	// except keepID. An empty keepID keeps none of them.
	//
	// It is what a password change and a password reset need, and until it existed
	// they could not do it: a reset that leaves the other sessions open leaves
	// whoever forced the reset signed in on their own machine, which is the exact
	// person the reset is aimed at. Deleting by id one at a time was not an option
	// -- nothing knew which ids belonged to the account.
	//
	// The tenant is part of the question, not a filter applied afterwards (RULE
	// 14). Two tenants may both hold a subject called "1", and signing one of them
	// out must not touch the other.
	//
	// It is not an error for the subject to have no sessions. It IS an error for
	// the tenant or the subject id to be empty: neither names a subject, and an
	// implementation that treats the empty id as one signs out every session
	// nobody has signed in on. Both refusals are made here as well as in
	// SessionStore.DestroyOthers, because this interface is exported and an
	// implementation is reachable without the store.
	DeleteSubject(ctx context.Context, tenant, subjectID, keepID string) error
}

SessionBackend stores the subject behind a session id.

The core ships MemoryBackend only, which is enough for development and for a single instance. The redis adapter provides the distributed store with active invalidation; see docs/05-repositorios.md.

type SessionOption added in v0.25.0

type SessionOption func(*sessionSettings)

SessionOption adjusts how a session is started.

A variadic option and not a second constructor: StartFor beside Start would be two functions that both start a session, and the next thing anybody needs -- a session for a device, for an impersonation, for a longer window -- adds a third. One function that takes options widens; a second name forks (RULE 9). Every existing call to Start and Rotate passes none and behaves as it did.

func Remember added in v0.25.0

func Remember(on bool) SessionOption

Remember asks for a session that survives closing the browser, for RememberLifetime instead of the store's ttl.

It takes the answer rather than being a flag, so the call site is the form field and there is no branch around it:

store.Rotate(ctx, w, old, sub, security.Remember(r.PostFormValue("remember") != ""))

The sign-in screen the starter kit publishes has drawn that checkbox from the beginning, and nothing could read it: there was no shape in this API through which a longer session could be asked for, so the box was decoration in every project the kit created.

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore issues and validates sessions.

The cookie value is the session id plus an HMAC of it. The signature is checked before the backend is touched, so a forged cookie never reaches the store -- and, in a distributed store, never costs a network round trip.

func NewSessionStore

func NewSessionStore(appKey []byte, ttl time.Duration, secure bool, b SessionBackend) *SessionStore

NewSessionStore returns a store. Pass secure=false only in development: without the Secure attribute the cookie travels over plain HTTP.

func (*SessionStore) Confirm added in v0.25.0

Confirm records on the request's session that the subject has just typed their password again.

It is the write half of a step-up check: a sensitive action asks Subject.PasswordConfirmedWithin, sends the person to a password screen when the answer is no, and calls this once they get it right. Without the stamp the only two designs available were asking for the password on every sensitive action, which people route around, and asking once and never again, which is not a check.

It rewrites the record, and rewriting it restarts the record's clock, so the cookie is rewritten with the same lifetime in the same breath. The session therefore gets a full lifetime back -- earned by proving who is holding it, which is the same proof that started it.

It returns ErrConfirmationNotStored when the backend accepted the stamp and did not keep it. A handler must report that rather than redirect: redirecting sends the person back to the screen they just got right.

func (*SessionStore) Destroy

func (s *SessionStore) Destroy(ctx context.Context, w http.ResponseWriter, id string) error

Destroy removes the session and clears the cookies this store put in the browser -- the session, and the intended destination.

The second one is not tidiness. Signing out is the moment a shared machine changes hands, and the intended address outlives it by up to IntendedLifetime: whoever signs in next is carried to the page the previous person was refused. That is somebody else's address bar handed to a stranger -- "/customers/98213/invoices/44" says who the last person was working on -- and it reads to the new person as the application taking them somewhere at random. The guards will still refuse them anything they may not open, so what is closed here is the disclosure and the confusion, not an authorization hole.

Every call site of Destroy in this framework and in the projects it ships is a sign-out, which is why the clearing is unconditional rather than an option: an address remembered before a sign-out is an address nobody wants afterwards.

func (*SessionStore) DestroyOthers added in v0.25.0

func (s *SessionStore) DestroyOthers(ctx context.Context, sub Subject, keepID string) error

DestroyOthers signs the subject out of every session except keepID.

Pass the id of the session doing the asking to keep the person signed in where they are -- a password change from the account screen -- and pass an empty keepID to end all of them, which is what a password reset from an e-mail link wants: there is no session to keep, and the one session that must stop working belongs to whoever forced the reset.

It does not touch the cookie. The kept session's cookie is still valid and every other browser is holding a cookie whose record is gone, which is a session that stops at the next request -- there is no way to reach into those browsers and no need to.

The tenant comes from the subject, which came from the Grant or the session, never from the request (RULE 14). A subject with no tenant is refused rather than turned into a query that matches an id across every customer.

func (*SessionStore) IDFromRequest

func (s *SessionStore) IDFromRequest(r *http.Request) string

IDFromRequest returns the session id when the cookie signature is valid, and the empty string otherwise. It is the function to hand to the CSRF middleware, which binds its token to this id.

func (*SessionStore) Load

func (s *SessionStore) Load(ctx context.Context, r *http.Request) (Subject, error)

Load returns the subject bound to the request's session cookie.

func (*SessionStore) RememberIntended added in v0.25.0

func (s *SessionStore) RememberIntended(w http.ResponseWriter, r *http.Request)

RememberIntended records where this request was going, so that the sign-in screen it is about to be sent to can finish the journey.

It is Laravel's redirect()->guest(): the guard is the only thing that knows what the person was reaching for, and by the time they have typed a password that request is gone. Without it every sign-in ends at the front page, and somebody who followed a link to one invoice has to find it again.

Why it is signed rather than merely same-site

The value decides where a browser goes immediately after authenticating, so whoever can write it can choose where every person in the application lands. SameSite=Strict stops another SITE from setting it, and does not stop another HOST on the same registrable domain: a cookie set on ".example.com" by anything holding a subdomain -- a customer's CNAME, a forgotten staging box, a vendor's status page -- arrives here indistinguishable from ours. An HMAC does stop it, because the attacker does not have the application key, and it comes with the expiry signed into the same bytes so a stale address cannot be replayed by keeping the cookie alive.

The destination is checked for being local anyway, on the way in and on the way out, because a signature only proves that WE wrote the value.

What it declines to remember

Only a GET, because the address is replayed by a browser navigation: a POST remembered here is a form submission turned into a link, which either answers 405 or, on a route that also accepts GET, performs something the person did not ask for a second time.

Only a whole page, never an HTMX fragment. A hx-get that is refused would otherwise be remembered as "/inbox/rows?page=2", and after signing in the person lands on that partial: no layout, no navigation, and it reads as a broken deploy. A boosted navigation is a page and is kept -- hx-boost is how most links in this stack are followed, so dropping it would drop nearly everything.

It writes nothing when there is nothing worth writing, so a caller has no branch to get wrong.

func (*SessionStore) Rotate

func (s *SessionStore) Rotate(ctx context.Context, w http.ResponseWriter, oldID string, sub Subject, opts ...SessionOption) (string, error)

Rotate issues a new session id for the same subject and destroys the old one.

It MUST be called on login: keeping the pre-login id is session fixation, the bug that lets an attacker plant a known id and inherit the session after the victim authenticates. `aru doctor` checks for this call. The options are the same as Start's, and Rotate takes them because login is where remember-me is answered: the sign-in handler calls Rotate, not Start, so an option only Start accepted would be unreachable from the one screen that has the checkbox on it.

func (*SessionStore) Start

func (s *SessionStore) Start(ctx context.Context, w http.ResponseWriter, sub Subject, opts ...SessionOption) (string, error)

Start creates a session for the subject and writes the cookie.

With no options it is what it has always been: a session for the store's configured ttl. See Remember for the only thing there is to ask for.

func (*SessionStore) TakeIntended added in v0.25.0

func (s *SessionStore) TakeIntended(w http.ResponseWriter, r *http.Request, fallback string) string

TakeIntended returns the address RememberIntended stored, and clears it.

It is Laravel's redirect()->intended($fallback), and it is meant to be the whole of a sign-in handler's last line:

httpx.Redirect(w, r, sessions.TakeIntended(w, r, "/"))

The fallback is a parameter rather than a constant because it is the one part that genuinely differs -- a blog sends people to the front page, an application to its dashboard -- and taking it here means no caller has to write the branch for "there was nowhere in particular".

It answers the fallback for anything it cannot prove

A forged or foreign signature, an expired one, a value that is not a local address: all of them are somebody's redirect that is not ours, and none of them is worth telling the person about at the moment they have just signed in. The check that the address is local is what keeps this from being an open redirect -- "sign in and then continue to https://evil.example/login" is the oldest phishing link there is, and it belongs here rather than in each project's handler precisely because every project would otherwise have to remember it.

It is consumed, not read: the cookie is cleared whatever it said, so the address is used once. An address left standing is one a person meets again at the next sign-in from this browser, weeks later, with no idea why.

type SignInThrottle added in v0.25.0

type SignInThrottle interface {
	// Attempt records one sign-in attempt against this identity from this
	// address and reports whether it may go ahead. A refused attempt costs
	// nothing, so hammering a locked-out account does not extend the lockout.
	//
	// The tenant is part of the key: a rate limit shared across tenants is one
	// customer's traffic locking another customer's users out (RULE 14).
	Attempt(tenant, identity, client string) (retryAfter time.Duration, ok bool)

	// Refund gives back the unit Attempt took, for an attempt that never
	// reached the credential -- the users table was unreachable, the request was
	// cancelled. It gives back one unit and never clears the count, which is
	// what keeps it from being the way out of a lockout: an attempt that refunds
	// itself is worth exactly the nothing it cost.
	Refund(tenant, identity, client string)

	// Clear forgets what this identity failed from this address, and gives the
	// address back the unit this attempt took. Call it on a successful sign-in.
	//
	// The address's remaining count is deliberately left standing. One account
	// whose password the caller does know is exactly what a script walking a
	// stolen list has, and clearing the address's budget on success would let it
	// reset that budget every few tries.
	Clear(tenant, identity, client string)
}

SignInThrottle counts sign-in attempts, so that a leaked password list cannot be tried against an account faster than a person can type.

The unit is taken before the password is checked

This is the whole shape of the thing, and the first version got it wrong. It asked whether the account was locked out and recorded the failure afterwards, with an argon2 hash in between -- and between the question and the answer there is a tenth of a second in which nothing has been written down. Eight requests fired at the same instant all asked, all got "no", and all eight had their passwords checked: the budget was five and the burst got eight, and with a few hundred open sockets it would have got a few hundred. A counter that only counts attempts arriving one after another does not limit an attacker, who has no reason to wait for the previous answer.

So Attempt takes the unit at the moment it decides, under one lock, and the two calls below give it back. A successful sign-in forgets the identity's failures entirely; an attempt that never got as far as testing a credential gives back exactly what it took. What is left counted is what was actually tried and actually wrong.

Why this is not middleware.Limiter

A route limiter counts requests and has nothing to say about either of the other two calls, and those are the whole difference between a failure counter and a rate limit: forgetting on success is what keeps the person who finally remembers their password on the fifth try from being locked out by the four before it, and giving a unit back is what keeps a database outage from spending every account's budget. Widening Limiter would push both into every implementation of it, including the distributed one, which would mean nothing by them. And it could not be done anyway: httpx imports security, so security naming a type from httpx/middleware is an import cycle. The layering already decided this one.

So: a second interface, and deliberately not a second mechanism. The in-memory implementation below is what the core ships; a kv-backed one has this same shape, which is what makes it an adapter rather than a mode (RULE 11).

type Signer added in v0.19.0

type Signer struct {
	// contains filtered or unexported fields
}

Signer issues links that prove something without storing anything.

It is what an e-mail verification link is made of, and what an unsubscribe link should be made of. The alternative -- a table of tokens -- costs a write, a read, a cleanup job and a decision about what happens when the row is gone; this costs a signature, and a link that has expired says so rather than saying "unknown token" three months after the row was deleted.

Two properties are what make it safe to put in a URL:

  • The purpose is signed. A token issued to verify an address does not work on a password reset, even though both are signed with the same key. Reusing one for the other is the mistake this prevents, and it is not an unlikely one: both are "a link in an e-mail with an id in it".
  • The expiry is signed. Moving it is changing the payload, so a link that has run out cannot be extended by editing the URL.

What it deliberately does NOT do is make the link single-use. That needs state, and where single use matters -- a password reset -- the state is the password itself: the token carries the current hash, so using the link changes what it was signed against. See ADR 0032.

func NewSigner added in v0.19.0

func NewSigner(appKey []byte) *Signer

NewSigner returns a Signer over the application key.

The same key as the session and the CSRF token, because they are the same secret: an attacker who has it does not need three.

func (*Signer) Sign added in v0.19.0

func (s *Signer) Sign(purpose, payload string, ttl time.Duration) string

Sign returns a token carrying payload, valid for ttl, usable only for purpose.

The payload is not secret -- it is base64 in a URL, and anyone can read it. What the signature buys is that nobody can change it.

func (*Signer) Verify added in v0.19.0

func (s *Signer) Verify(purpose, token string) (string, error)

Verify checks a token and returns what was signed into it.

The order matters: the signature is checked before the expiry is read, because an unsigned expiry is a number the client chose.

type Subject

type Subject struct {
	ID     string
	Tenant string
	Roles  []string

	// Verified says whether the address behind this account was confirmed.
	//
	// It is on the subject and not read from the database per request, because
	// the question is asked by policies -- on every write, sometimes twice --
	// and a database round trip inside an authorization check is a round trip
	// nobody can see from the call site.
	//
	// The cost is that it is as old as the session: somebody who verifies while
	// signed in stays unverified until they sign in again. That is the right
	// trade for the direction it fails in -- an account is created unverified,
	// so a stale session can only be MORE restrictive than the truth, never
	// less.
	Verified bool

	// Remembered says whether this session was started with the remember-me box
	// ticked, and therefore lives for RememberLifetime instead of the store's
	// configured ttl.
	//
	// It is on the record and not recomputed, because the session store rewrites
	// the record when the password is confirmed and has to write back the same
	// lifetime it started with. Without it, confirming a password on a remembered
	// session silently cut it down to the plain ttl -- somebody who ticked the box
	// and then confirmed a payment was signed out that evening.
	//
	// Only SessionStore.Start and SessionStore.Rotate set it, from the Remember
	// option, and they overwrite whatever the caller put here: a field set by hand
	// on the way in would be a second way to ask for a longer session, and there
	// is one (RULE 9).
	//
	// A policy may also read it. Laravel exposes the same fact as viaRemember, and
	// for the same use: a session nobody has authenticated for a month is the
	// right moment to ask for the password again before a destructive action.
	Remembered bool

	// PasswordConfirmedAt is when the subject last typed their password again on
	// an already open session -- Laravel's auth.password_confirmed_at.
	//
	// It exists so a sensitive action can demand the password once and then leave
	// the person alone for a while, instead of on every click. Ask through
	// PasswordConfirmedWithin, never by comparing this field: the zero value has
	// to mean unconfirmed, and a comparison written at the call site is where that
	// gets forgotten.
	//
	// It is carried on the subject for the same reason Verified is, and it fails
	// in the same direction: a session written by an older binary has no stamp, so
	// it reads as never confirmed and the person is asked for their password. The
	// opposite default -- treating an absent stamp as recent -- would let every
	// session that survived a deploy walk past the confirmation screen.
	PasswordConfirmedAt time.Time
	// contains filtered or unexported fields
}

Subject is whoever is acting. It comes from the session, never from the request body.

func Guest added in v0.16.0

func Guest(tenant string) Subject

Guest is a reader with no session, declared on purpose.

It exists because a public page is a real requirement and the alternative was worse. Authorize refuses an empty subject before it consults a policy -- which is right, because an empty subject is almost always a forgotten session load -- and that left no way at all to say "anybody may read a published post". The only path was security.SystemGrant, which skips the policy entirely: a blog served with the same instrument a scheduled job uses.

So the refusal stays and the exception is explicit. A zero Subject is still refused. This one reaches the policy, and the POLICY decides:

func (PostPolicy) Can(ctx context.Context, s security.Subject, a security.Action, p models.Post) error {
	if s.IsGuest() {
		if a == PostView && !p.PublishedAt.IsZero() {
			return nil
		}
		return fmt.Errorf("%s is not public", a)
	}
	…
}

Nothing is loosened by this. Authorization still happens in one place, the Grant is still the only way to a repository, and a policy that says nothing about guests denies them -- which is what every generated policy does, so the default is closed.

The tenant is required and is the application's, from configuration. A visitor cannot choose whose rows they read, and RULE 14 is not suspended because nobody signed in.

func (Subject) HasRole

func (s Subject) HasRole(r string) bool

HasRole reports whether the subject carries the given role.

func (Subject) IsGuest added in v0.16.0

func (s Subject) IsGuest() bool

IsGuest reports whether this subject is a declared anonymous reader.

A policy that never asks denies them, because it will fall through to its final refusal -- HasRole answers false for a guest, and there is no id to compare an owner against.

func (Subject) PasswordConfirmedWithin added in v0.25.0

func (sub Subject) PasswordConfirmedWithin(window time.Duration) bool

PasswordConfirmedWithin reports whether the password was typed again on this session less than window ago.

It answers false whenever it cannot prove otherwise, which is the whole argument for having it be a method rather than a comparison at the call site:

  • No stamp is not confirmed. A session written by an older binary, or by a backend that does not carry the field yet, has the zero time -- and the reading that costs somebody one password screen is the correct one, while the reading that treats an absent stamp as recent waves every session that survived a deploy straight past the check.
  • A stamp in the future is not confirmed either. It is a clock that moved or a record that was tampered with, and neither is proof that a person was there.
  • A window of zero or less is not confirmed, so "no window configured" cannot read as "always confirmed".

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL