Documentation
¶
Overview ¶
Package pin implements sign-in with a short numeric PIN on a device that is then bound to the user: the pattern of an offline field app, where an operator sets a PIN, the phone signs in once, and from then on holds a long-lived, revocable device token instead of asking for a password.
It is not the device package, which is the RFC 8628 grant a CLI uses to borrow a browser session. Here the device itself is the credential.
The flow ¶
- An operator calls SetPIN (or ClearPIN). A new PIN lifts a lock.
- The device calls SignIn with a login and the PIN. The login is resolved by the host through LoginResolver, so it can be a personnel number as readily as an email address. SignIn returns a device token, shown once and stored as its hash.
- Every request from the device calls ValidateDevice with that token.
- The office revokes a lost device with RevokeDevice or every device of a user with RevokeAllDevices; the device signs itself out with RevokeDeviceByToken.
Guessing ¶
A six-digit PIN is a million guesses, and its hash protects it from nobody who has the table. What protects it is the limit on wrong guesses: Config.MaxFailedAttempts within Config.LockDuration lock the login, 5 in 15 minutes by default, so at most that many PINs can be tried per window. The failures are recorded in the store.LockoutStore the user plane uses, under keys of this package's own, and Config.RateLimiter is consulted first, by source address and by login, before any hash is computed.
The answer to a wrong login and to a wrong PIN is the same ErrInvalidCredentials, and an unknown login locks like a known one.
Coming from another implementation ¶
PIN hashes in bcrypt verify as they are, and are re-hashed to the configured Hasher (Argon2id by default) at the next successful sign-in; ImportPINHash stores one. Device tokens are stored as hex(SHA-256(token)), the format crypto.HashToken writes, so a table of such hashes imports unchanged through ImportDevice and the devices stay signed in.
Not here yet ¶
Binding a device once through a stronger sign-in (a magic link, OIDC) and using the PIN only on that device afterwards is not implemented. The device rows do not record how they were bound, so such a mode can bind the same rows; SignIn is the only binding method today.
Index ¶
- Variables
- type Config
- type Hooks
- type ImportedDevice
- type LoginResolver
- type LoginResolverFunc
- type Service
- func (s *Service) ClearPIN(ctx context.Context, operatorID, userID string) error
- func (s *Service) ImportDevice(ctx context.Context, in ImportedDevice) (store.BoundDevice, error)
- func (s *Service) ImportPINHash(ctx context.Context, operatorID, userID, hash string) error
- func (s *Service) ListDevices(ctx context.Context, userID string) ([]store.BoundDevice, error)
- func (s *Service) RevokeAllDevices(ctx context.Context, actorID, userID string) (int, error)
- func (s *Service) RevokeDevice(ctx context.Context, actorID, deviceID string) error
- func (s *Service) RevokeDeviceByToken(ctx context.Context, token string) error
- func (s *Service) SetPIN(ctx context.Context, operatorID, userID, pin string) error
- func (s *Service) SignIn(ctx context.Context, login, pin, deviceName, userAgent, ipAddress string) (SignInResult, error)
- func (s *Service) ValidateDevice(ctx context.Context, token string) (store.User, store.BoundDevice, error)
- type SignInResult
- type Stores
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidCredentials is every failed SignIn that is not a lock: an // unknown login, a user without a PIN, and a wrong PIN all return it, // so the answer never says which of the two was wrong. ErrInvalidCredentials = errors.New("authit/pin: login or PIN incorrect") // ErrLocked means too many wrong PINs were entered for this login // within Config.LockDuration, or an operator locked the account. It is // returned for an unknown login too, once it has collected as many // failures, so it reveals no more than ErrInvalidCredentials does. ErrLocked = errors.New("authit/pin: sign-in locked after too many wrong PINs") // ErrInvalidToken is a device token that is unknown, revoked, expired, // or whose user no longer exists. ErrInvalidToken = errors.New("authit/pin: device not signed in, or revoked") // ErrAccountLocked is ValidateDevice for a user an operator has locked // (store.LockoutStore.LockAccount): the device is still bound, but may // not be used until the account is unlocked. ErrAccountLocked = errors.New("authit/pin: account locked") // ErrInvalidPIN is SetPIN with a PIN that is not exactly // Config.PINLength digits. ErrInvalidPIN = errors.New("authit/pin: PIN has the wrong length or is not all digits") // ErrInvalidImport is ImportPINHash or ImportDevice with a hash in a // format authit cannot verify. ErrInvalidImport = errors.New("authit/pin: hash in an unrecognised format") // ErrRateLimited is returned when Config.RateLimiter refuses a sign-in. // Alias for ratelimit.ErrRateLimited. ErrRateLimited = ratelimit.ErrRateLimited )
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// PINLength is the number of digits SetPIN requires. Defaults to 6.
PINLength int
// MaxFailedAttempts wrong PINs within LockDuration lock the login.
// Defaults to 5.
MaxFailedAttempts int
// LockDuration is the window failures are counted in, and so the
// longest a lock lasts: it lifts as the failures age out of it.
// Defaults to 15 minutes.
LockDuration time.Duration
// DeviceTokenTTL, when positive, signs a device out that long after
// it was bound. Zero, the default, keeps it signed in until revoked,
// for devices that spend long stretches offline.
DeviceTokenTTL time.Duration
// LastUsedInterval is how stale a device's LastUsedAt may get before
// ValidateDevice writes it again, so a busy device does not cost a
// write per request. Defaults to one minute; negative writes it on
// every use.
LastUsedInterval time.Duration
// Hasher hashes new PINs. Defaults to crypto.DefaultHasher, Argon2id.
// Whatever it is, bcrypt and Argon2id hashes both verify.
Hasher authitcrypto.Hasher
// RateLimiter is consulted before every sign-in, by source address and
// by login. Nil means ratelimit.Noop.
RateLimiter ratelimit.Limiter
// AuditLogger receives the package's events. Nil means none are
// recorded.
AuditLogger audit.Logger
Hooks Hooks
}
Config tunes the pin package.
type Hooks ¶
type Hooks struct {
// BeforeBind runs once the PIN has been verified and before the device
// is bound. It is where a host refuses a user who may sign in in
// principle but not here: an inactive employee, a role that has no
// business on the field app. It runs after the PIN check on purpose,
// so its answer reaches only someone who knows the PIN.
BeforeBind func(ctx context.Context, u store.User) error
// AfterSignIn runs once the device is bound. With Stores.Tx set it runs
// in the same transaction, and its error undoes the binding.
AfterSignIn func(ctx context.Context, u store.User, d store.BoundDevice) error
}
Hooks are optional callbacks into the host, in the shape of user.Hooks: every field is nil-safe, and a hook's error is returned unchanged.
type ImportedDevice ¶
type ImportedDevice struct {
UserID string
Name string
// TokenHash is hex(SHA-256(token)), upper or lower case.
TokenHash string
UserAgent string
IPAddress string
ExpiresAt *time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
// CreatedAt is when the device was bound; zero means now.
CreatedAt time.Time
}
ImportedDevice is a device bound by another implementation, carried over by ImportDevice with the hash of its token.
type LoginResolver ¶
type LoginResolver interface {
ResolveLogin(ctx context.Context, login string) (userID string, err error)
}
LoginResolver turns what a person types into the login field into the id of the authit user it names. It is the host's, because only the host knows what a login is in its domain: a personnel number, an email address, either.
An unknown login is store.ErrNotFound; any other error is a fault and is returned from SignIn as it is.
func ByEmail ¶
func ByEmail(users store.UserStore) LoginResolver
ByEmail resolves a login as the email address of an authit user.
type LoginResolverFunc ¶
LoginResolverFunc adapts a function to LoginResolver.
func (LoginResolverFunc) ResolveLogin ¶
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service implements PIN sign-in and device binding.
func NewService ¶
func NewService(stores Stores, resolver LoginResolver, cfg Config) (*Service, error)
NewService constructs a Service. Every Stores field but Tx is required, and so is resolver.
func (*Service) ClearPIN ¶
ClearPIN removes userID's PIN, so they can no longer sign in a new device. Devices already bound stay bound; RevokeAllDevices ends those.
func (*Service) ImportDevice ¶
func (s *Service) ImportDevice(ctx context.Context, in ImportedDevice) (store.BoundDevice, error)
ImportDevice carries over a device bound by another implementation that stored hex(SHA-256(token)), so the device keeps its token and stays signed in. The hash is checked for that shape and stored lower-case, which is what crypto.HashToken produces when ValidateDevice looks it up.
func (*Service) ImportPINHash ¶
ImportPINHash stores a PIN hash written elsewhere -- bcrypt ($2a$, $2b$, $2y$) or Argon2id in PHC form -- as userID's PIN, and lifts a lock. A bcrypt hash is re-hashed with Config.Hasher at the next successful sign-in, when the PIN is at hand.
func (*Service) ListDevices ¶
ListDevices lists every device userID has signed in on, revoked ones included so an office can see what was revoked; filter on RevokedAt and ExpiresAt as needed.
func (*Service) RevokeAllDevices ¶
RevokeAllDevices signs every device of userID out, and reports how many were still signed in.
func (*Service) RevokeDevice ¶
RevokeDevice signs the device deviceID out at once: a lost phone. actorID is who revoked it, for the audit trail; whether they may is the caller's decision. Revoking a revoked device is not an error; an unknown one is store.ErrNotFound.
func (*Service) RevokeDeviceByToken ¶
RevokeDeviceByToken is the device signing itself out. It is idempotent: an unknown or already revoked token is not an error.
func (*Service) SetPIN ¶
SetPIN sets userID's PIN, replacing any PIN they had, and lifts a lock from wrong PINs. operatorID is who set it, for the audit trail; whether they may is the caller's decision.
func (*Service) SignIn ¶
func (s *Service) SignIn(ctx context.Context, login, pin, deviceName, userAgent, ipAddress string) (SignInResult, error)
SignIn checks login and pin and, when both are right, binds a new device for the user and returns its token.
Every failure that is not a lock is ErrInvalidCredentials, whether the login was unknown, the user had no PIN, or the PIN was wrong, and each costs the same hash computation. Config.MaxFailedAttempts of them within Config.LockDuration make SignIn return ErrLocked for that login, a known one or not, until they age out or an operator sets a new PIN. The failures of a known user are counted per user, so the several logins that resolve to one person share one budget.
A login an operator has locked (store.LockoutStore.LockAccount) is ErrLocked too. Hooks.BeforeBind can refuse a user once the PIN is verified; its error is returned as it is.
func (*Service) ValidateDevice ¶
func (s *Service) ValidateDevice(ctx context.Context, token string) (store.User, store.BoundDevice, error)
ValidateDevice resolves a device token to its user and device, and records the use (at most once per Config.LastUsedInterval).
An unknown, revoked or expired token, or one whose user is gone, is ErrInvalidToken; a user an operator has locked is ErrAccountLocked. A store failure is returned as itself, so a caller can answer it with a 500 rather than signing the device out.
type SignInResult ¶
type SignInResult struct {
// Token is the device's bearer credential. It is returned here once and
// stored only as its hash; hand it to the device and nowhere else.
Token string
Device store.BoundDevice
User store.User
}
SignInResult is a successful SignIn.
type Stores ¶
type Stores struct {
Users store.UserStore
PINs store.PINStore
Devices store.BoundDeviceStore
Lockouts store.LockoutStore
// Tx is optional. Supplying it makes binding the device and the
// AfterSignIn hook one step, so a hook's error leaves no device
// behind. See store.TxRunner.
Tx store.TxRunner
}
Stores groups the persistence ports the pin package needs.