Documentation
¶
Overview ¶
Package account owns people: users, the teams they belong to, the role they hold in each, and the sessions and invitations that get them there.
Index ¶
- Constants
- Variables
- func HashToken(raw string) []byte
- func NewToken() (raw string, hash []byte, err error)
- type Invitation
- type Member
- type Membership
- type Method
- type Proof
- type Role
- type Service
- func (s *Service) AcceptInvitation(ctx context.Context, raw string, userID uuid.UUID) (string, Role, error)
- func (s *Service) AuthenticatePassword(ctx context.Context, email, plain string) (User, error)
- func (s *Service) BootstrapOwner(ctx context.Context, teamID, teamName string, user User) error
- func (s *Service) ConsumeMagicLink(ctx context.Context, raw string) (User, error)
- func (s *Service) CreateSession(ctx context.Context, userID uuid.UUID, teamID, userAgent, ip string, ...) (string, error)
- func (s *Service) CreateTeam(ctx context.Context, id, name string, ownerUserID uuid.UUID) (Team, error)
- func (s *Service) EnsureUser(ctx context.Context, email, displayName string) (User, error)
- func (s *Service) HasPassword(ctx context.Context, userID uuid.UUID) (bool, error)
- func (s *Service) InvitationFor(ctx context.Context, raw string) (Invitation, error)
- func (s *Service) Invite(ctx context.Context, actor uuid.UUID, teamID, email string, role Role, ...) (string, error)
- func (s *Service) IssueMagicLink(ctx context.Context, email string, ttl time.Duration) (string, User, bool, error)
- func (s *Service) ListMembers(ctx context.Context, teamID string) ([]Member, error)
- func (s *Service) ListPendingInvitations(ctx context.Context, teamID string) ([]Invitation, error)
- func (s *Service) Provider(cookieName string) *Sessions
- func (s *Service) RemoveMember(ctx context.Context, actor uuid.UUID, teamID string, target uuid.UUID) error
- func (s *Service) RemovePassword(ctx context.Context, sessionID uuid.UUID, proof Proof) error
- func (s *Service) ResolveSession(ctx context.Context, raw string) (Session, error)
- func (s *Service) RevokeAllSessions(ctx context.Context, userID uuid.UUID) error
- func (s *Service) RevokeInvitation(ctx context.Context, actor uuid.UUID, teamID string, id uuid.UUID) error
- func (s *Service) RevokeOtherSessions(ctx context.Context, userID, keep uuid.UUID) error
- func (s *Service) RevokeSession(ctx context.Context, raw string) error
- func (s *Service) RoleIn(ctx context.Context, userID uuid.UUID, teamID string) (Role, error)
- func (s *Service) SetPassword(ctx context.Context, sessionID uuid.UUID, plain string, proof Proof) error
- func (s *Service) SetRole(ctx context.Context, actor uuid.UUID, teamID string, target uuid.UUID, ...) error
- func (s *Service) SwitchTeam(ctx context.Context, sessionID uuid.UUID, teamID string) error
- func (s *Service) TeamsFor(ctx context.Context, userID uuid.UUID) ([]Membership, error)
- func (s *Service) User(ctx context.Context, userID uuid.UUID) (User, error)
- type Session
- type Sessions
- type Team
- type User
Constants ¶
const DefaultCookieName = "yacht_session"
DefaultCookieName is the cookie the session provider reads.
const ( // MinPasswordLength is counted in runes rather than bytes so that a // passphrase in a non-Latin script is not held to a shorter limit than the // same number of characters in ASCII. // // Exported so that the form can say the number and the browser can enforce // it before a round trip does. The rule itself stays here — a minlength // attribute is a courtesy, not a check. MinPasswordLength = 12 )
const StepUpWindow = 10 * time.Minute
StepUpWindow is how long a session stays recently-authenticated.
Long enough to change a password and then think better of it without proving twice; short enough that a laptop somebody walked away from is not a credential.
Variables ¶
var ( ErrPasswordTooShort = errors.New("account: a password must be at least 12 characters") ErrPasswordTooLong = errors.New("account: that password is too long") )
ErrPasswordTooShort and ErrPasswordTooLong are two of the three things this package will refuse a password for.
There are no composition rules, deliberately. Every rule is a constraint an attacker subtracts from the search space, and in practice they push people towards a shorter password with a digit on the end. Length is the only lever that is monotonically good, which is why it is the only one pulled.
var ErrCredentialInvalid = errors.New("account: those sign-in details are not valid")
ErrCredentialInvalid is returned for every failed password sign-in.
One error for an unknown address, an address with no password, a wrong password, and a stored hash that will not parse. A caller that could tell them apart would be a user-list disclosure with extra steps, which is the same rule ErrTokenInvalid and ErrSessionInvalid already state.
var ErrForbidden = errors.New("account: not permitted")
ErrForbidden is returned when the actor's role does not carry the authority the change needs.
var ErrInvitationNotFound = errors.New("account: no such invitation")
ErrInvitationNotFound is returned when there is no such invitation to revoke in this team.
Distinct from success so a caller can say "there was nothing to revoke" rather than reporting that a still-live token has been withdrawn.
var ErrLastOwner = errors.New("account: a team must keep at least one owner")
ErrLastOwner is returned when a change would leave a team with no owner.
var ErrNeedsReauthentication = errors.New("account: this needs to be confirmed first")
ErrNeedsReauthentication is returned when a session has not proved who is holding it recently enough to change how it can be signed in to.
Not a failure to report as an error: it is the signal that asks the person to confirm, so it has to be distinguishable, which is why it is not folded into ErrForbidden.
var ErrNoPassword = errors.New("account: no password is set")
ErrNoPassword is returned when there was nothing to remove.
Distinct from success so that the page can say "you do not have one" rather than report that a credential which never existed has been withdrawn — which is how somebody stops looking for the one that is still there.
var ErrNotAMember = errors.New("account: not a member of this team")
ErrNotAMember is returned when a person holds no role in a team.
var ErrPasswordIsEmail = errors.New("account: a password cannot be your email address")
ErrPasswordIsEmail is the third. An address is the one string an attacker guessing this account already has in front of them.
var ErrSessionInvalid = errors.New("account: session is not valid")
ErrSessionInvalid is returned for every session that cannot be used.
One error for unknown, expired and malformed alike: a caller that could tell them apart could tell a real token from a guess, and so could anyone probing the endpoint that surfaces the difference.
var ErrTeamTaken = errors.New("account: a team with that id already exists")
ErrTeamTaken is returned when the team id already belongs to someone else.
var ErrTokenInvalid = errors.New("account: token is not valid")
ErrTokenInvalid is returned for every magic link and invitation that cannot be used.
One error for unknown, expired, already-consumed and malformed alike. A caller that could tell them apart would hand that difference to whoever is holding the token, and "this link has already been used" tells an attacker their guess was a real link.
Functions ¶
func HashToken ¶
HashToken returns the stored form of a token.
Plain SHA-256, deliberately: unlike a password this is full-entropy random, so there is nothing to brute-force and a slow KDF would only add latency to every request that carries a session cookie.
func NewToken ¶
NewToken returns a fresh credential and the hash to store for it.
One implementation shared by sessions, magic links and invitations, so all three get the same entropy and the same storage rule rather than three chances to get it wrong.
The caller sends raw to the person and stores hash. Never the reverse: a database dump must not be a set of working credentials.
Types ¶
type Invitation ¶
type Invitation struct {
ID uuid.UUID
TeamID string
Email string
Role Role
ExpiresAt time.Time
CreatedAt time.Time
}
Invitation is an outstanding invitation, as a page that offers to withdraw it needs it.
There is no token field, and there is no field the token can be derived from. The struct is the shape the team page renders, and a page that printed the invitation link would let anyone who can read it — including whoever is looking over the reader's shoulder — join as the invitee. Leaving the secret out of the type is what makes that impossible rather than merely avoided.
type Member ¶
Member is one person's place in a team, carrying enough of the person for a management page to name them without a query per row.
type Membership ¶
type Membership struct {
UserID uuid.UUID
TeamID string
TeamName string
Role Role
CreatedAt time.Time
}
Membership is a person's place in a team, carrying the team's name so a switcher can be rendered without a query per row.
type Method ¶
type Method string
Method names a way in.
It exists for the sign-in log line, where an operator reading an unexpected session needs to know which door it came through. It is deliberately not a field on Session and not an argument to anything that makes a decision: nothing downstream of a completed sign-in is allowed to behave differently by method, because that is how two ways in stop producing the same session.
type Proof ¶
type Proof struct {
CurrentPassword string
}
Proof is how a session re-proves who is holding it.
Empty means "rely on a recent sign-in". Somebody who has no password has no other option, and that is exactly the case adding a first password has to support — which is why this is a struct with one optional field rather than a required argument.
type Role ¶
type Role string
Role is what a person may do in a team.
The split is at the line where an action stops being undoable by redeploying: a member deploys, scales and redeploys; an admin can also delete apps, manage domains and storage, and invite people; an owner can also manage admins and the team itself.
func (Role) CanAdminister ¶
CanAdminister covers deleting apps, managing domains and storage, and inviting people.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service owns people, teams and the roles between them.
func NewService ¶
NewService wires the store into the account service.
func (*Service) AcceptInvitation ¶
func (s *Service) AcceptInvitation( ctx context.Context, raw string, userID uuid.UUID, ) (string, Role, error)
AcceptInvitation spends an invitation and returns the team and role it grants.
The invitation is spent and the membership written in one transaction, so an invitation can never be marked accepted by someone who did not end up in the team, and the token can never be spent twice.
func (*Service) AuthenticatePassword ¶
AuthenticatePassword verifies an address and a password and returns the person they prove.
Returns a User and nothing else, so that it produces exactly what ConsumeMagicLink produces and the caller opens a session the same way for both. Rate limiting is the caller's, because the limiter that exists is in-process and documents itself as a brake rather than a boundary. The equal cost of a hit and a miss is this function's, and is not something a caller could add afterwards.
func (*Service) BootstrapOwner ¶
BootstrapOwner gives someone with no team one to act as, handing the first of them the team the install was already running as.
This is the spec's third lockout guard. An install that switches accounts on has apps already deployed under YACHT_OWNER_ID; without this the first person to sign in lands in a fresh empty team and those apps belong to an owner nobody can authenticate as — visible only as a dashboard that has lost everything.
The configured team is claimed only when it has no owner at all, and that is read and acted on inside one transaction holding the team row. Two people completing sign-in at the same moment therefore produce exactly one owner: the loser takes the second branch and gets a team of their own. The check is "has no owner" rather than "has no members" so that an install whose team was left with members but no owner is still recoverable.
Idempotent: someone who already holds a role in the configured team is left exactly as they are, so a retried sign-in cannot mint a second team.
func (*Service) ConsumeMagicLink ¶
ConsumeMagicLink spends a sign-in link and returns the person it proves.
Spending and reading are one statement, so a link cannot be followed twice — a link sits in a mailbox forever, and one that still worked the second time would be a permanent key to the account.
func (*Service) CreateSession ¶
func (s *Service) CreateSession( ctx context.Context, userID uuid.UUID, teamID, userAgent, ip string, ttl time.Duration, ) (string, error)
CreateSession issues a session for a person acting in a team, and returns the token to put in their cookie.
The token is returned once and never again: only its hash is stored, so a database dump is not a set of live sessions.
Membership of teamID is checked here rather than trusted from the caller. The active team is what every downstream query is scoped by, so a session opened onto a team the person does not belong to is a silent grant of that team's apps.
func (*Service) CreateTeam ¶
func (s *Service) CreateTeam( ctx context.Context, id, name string, ownerUserID uuid.UUID, ) (Team, error)
CreateTeam creates a team and makes the creator its owner.
Both writes happen in one transaction: a team whose creator is not a member is a team nobody can administer, and it would take a database edit to fix.
An id already held by another team is refused rather than adopted. The underlying upsert exists so an operator can re-run creation idempotently, but without this check picking an existing id would hand the caller ownership of that team's apps.
func (*Service) EnsureUser ¶
EnsureUser returns the user for an address, creating them if new.
The address is lowercased on the way in, because a person who signs up as Alice@ and is later invited as alice@ is one person. An empty display name leaves the stored one alone: sign-in knows the address and not the name, and must not blank out what the person set.
func (*Service) HasPassword ¶
HasPassword reports whether an account can be signed in to with one.
Answers the account page, which already knows who it is rendering for, so it takes a user id rather than a session. It is never called from a signed-out path: "does this address have a password" is precisely the disclosure the sign-in design exists to withhold.
func (*Service) InvitationFor ¶
InvitationFor reads an invitation without spending it.
Needed because acceptance is bound to the address the invitation names, so a signed-out visitor cannot simply be let in on the strength of holding the token. Reading it tells the caller which mailbox to prove, and spends nothing if they cannot.
func (*Service) Invite ¶
func (s *Service) Invite( ctx context.Context, actor uuid.UUID, teamID, email string, role Role, ttl time.Duration, ) (string, error)
Invite issues an invitation to join a team, and returns the token to mail.
The actor's authority is checked with the same rule that governs role changes: inviting is administration, and an admin cannot invite anyone who could administer, because appointing an admin is a step away from appointing an owner.
Inviting an address that already has an outstanding invitation replaces it, so the older token stops working. Two live tokens for one address would mean revoking the invitation on screen leaves the other one usable.
func (*Service) IssueMagicLink ¶
func (s *Service) IssueMagicLink( ctx context.Context, email string, ttl time.Duration, ) (string, User, bool, error)
IssueMagicLink mints a sign-in link for an address, if anyone holds it.
existed reports whether the address is registered, and is the only thing that differs between the two cases. The caller mails a link when it is true and nothing when it is false, while answering the visitor identically either way: a sign-in form that responds differently to a registered address is a user-list disclosure.
An unknown address is deliberately not registered here. Doing so would let anyone fill the user table from the sign-in form, and would make the first sign-in of a stranger indistinguishable from that of an invited person.
func (*Service) ListMembers ¶
ListMembers returns everyone in a team, with the role each holds.
Scoped by team and by nothing else. There is no actor to check here because the caller has already been proved to be acting in this team — the role gate on the route resolved the session, and the team id it hands over is that session's own active team rather than anything the request supplied. A team id that arrived from a form would make this a directory of everybody.
func (*Service) ListPendingInvitations ¶
func (s *Service) ListPendingInvitations( ctx context.Context, teamID string, ) ([]Invitation, error)
ListPendingInvitations returns the invitations to a team that nobody has accepted and that have not expired.
Scoped by team, for the same reason ListMembers is: the team id comes from the caller's own session, which the role gate has already resolved.
func (*Service) RemoveMember ¶
func (s *Service) RemoveMember( ctx context.Context, actor uuid.UUID, teamID string, target uuid.UUID, ) error
RemoveMember takes a person out of a team.
func (*Service) RemovePassword ¶
RemovePassword takes the password off an account, leaving the magic link.
Allowed deliberately: the person can still get in through their mailbox, so this cannot lock anybody out, and refusing it would mean a password added once can never be taken back. Every other session goes with it — removing a password is what somebody does when they believe it is known.
func (*Service) ResolveSession ¶
ResolveSession returns the session a token stands for.
The presented token is hashed and looked up by hash; raw values are never compared, because there is no raw value stored to compare against.
func (*Service) RevokeAllSessions ¶
RevokeAllSessions ends every session a person holds.
This is the answer to a stolen cookie. Without it a leaked session runs until it expires and there is nothing the person can do about it.
func (*Service) RevokeInvitation ¶
func (s *Service) RevokeInvitation( ctx context.Context, actor uuid.UUID, teamID string, id uuid.UUID, ) error
RevokeInvitation withdraws an invitation that has not been accepted.
Deleting the row is what takes the token out of circulation, which is the only way to take back an invitation sent to the wrong address — the mail itself cannot be recalled.
func (*Service) RevokeOtherSessions ¶
RevokeOtherSessions ends every session a person holds except one.
RevokeAllSessions cannot serve here: it takes out the session doing the asking, so a change made from the account page would sign the person out of the browser they are looking at, and they would read that as the change having failed rather than having worked.
func (*Service) RevokeSession ¶
RevokeSession ends one session — the sign-out on this browser.
func (*Service) RoleIn ¶
RoleIn returns the role a person holds in a team.
Absence is ErrNotAMember rather than an empty role, so a caller cannot mistake "no membership" for "least privilege" and let a stranger in as a member.
func (*Service) SetPassword ¶
func (s *Service) SetPassword( ctx context.Context, sessionID uuid.UUID, plain string, proof Proof, ) error
SetPassword adds or replaces the password on the account holding a session.
It takes a session id and not a user id, for the reason SwitchTeam takes one: whose password this is comes from the session row, never from anything the request supplied. A user id in this signature would be a way to set anybody's password by knowing their id, and the only thing standing in the way would be every caller remembering to check.
Recent authentication is verified here rather than trusted from the caller, the same way CreateSession verifies membership itself. Adding a password is how a stolen cookie becomes a permanent credential, so the check that stops that must not be skippable by a handler that forgot to make it.
No old password is required as such, and that is not a relaxation. Somebody adding their first password has no old one, so demanding it for a change but not for an add would be two flows, two forms and two chances to get the recency check wrong. One Proof covers both, and for a person who does have a password, typing it is what satisfies it.
Replacing a password ends every other session; adding a first one does not. Gaining a way in is not a change of trust, and signing somebody out of their other devices for it reads as the feature having broken something. The difference is read inside the transaction, so it cannot race.
func (*Service) SetRole ¶
func (s *Service) SetRole( ctx context.Context, actor uuid.UUID, teamID string, target uuid.UUID, role Role, ) error
SetRole gives target a role in a team, adding them if they are not a member.
func (*Service) SwitchTeam ¶
SwitchTeam points a session at another of the person's teams.
Membership is checked against the session's own user, not against anything the request supplied, so a crafted team id switches into nothing.
func (*Service) TeamsFor ¶
TeamsFor returns every team a person belongs to, with the role they hold.
func (*Service) User ¶
User returns the person a user id names.
Takes an id rather than an address, because every caller already holds one from a session they resolved. A lookup by address reachable from a handler would be a way to ask whether somebody has an account, which is the disclosure the sign-in design exists to withhold.
type Session ¶
type Session struct {
ID uuid.UUID
UserID uuid.UUID
ActiveTeamID string
TeamName string
TeamEmail string
// Role the person holds in the active team. Carried on the session because
// resolving one already proves the membership exists — sub-project C's
// route gates would otherwise re-query it on every request.
Role Role
UserAgent string
IP string
ExpiresAt time.Time
CreatedAt time.Time
// AuthenticatedAt is when whoever holds this session last proved they are
// its owner, as opposed to when the session began. Set at creation and
// refreshed when a password is re-entered to confirm a change.
//
// Separate from CreatedAt because a session that re-proves itself is still
// the same session. Folding the two together would mean the only way to
// refresh the window is to mint a new one, which is a sign-in rather than a
// confirmation.
AuthenticatedAt time.Time
}
Session is a signed-in browser.
It carries the active team's name and address so the request that presented the cookie can be resolved to an owner in one round trip, the same reason Membership carries the team name.
func (Session) RecentlyAuthenticated ¶
RecentlyAuthenticated reports whether this session proved who holds it within d.
Read at the moment of a write and never at page render: a form that was rendered inside the window and submitted outside it must not be honoured, and the service checks this itself for exactly that reason.
type Sessions ¶
type Sessions struct {
// contains filtered or unexported fields
}
Sessions resolves the owner of a request from a session cookie.
The third identity.Provider the engine ships, alongside SingleOwner and StaticToken. It exists as a Provider rather than as a replacement for the seam so that every handler, the app service, the store and the orchestrator stay untouched: they already take an OwnerID and never ask where it came from, and a wrapping application still swaps the whole provider out.