Documentation
¶
Overview ¶
Package password is the password login provider: JSON endpoints over net/http for login by username or email, an optional TOTP second factor with backup codes, optional self-registration (off by default) and logout. Passwords are hashed with Argon2id; hashes of another scheme (bcrypt with VerifyBcrypt) verify through Options.LegacyVerifier and are upgraded to Argon2id at the next login.
Users live in the application's database behind Storage; MemoryStorage serves tests and examples. A successful login stores an auth.Identity in a session.Store session, which a session.Guard then authenticates; the Provider is also the session.Refresher that ends the sessions of deleted users.
Login answers 200 with the identity, or 202 {"challenge":"totp"} when the user has TOTP (verify the code at POST {prefix}/totp/verify) and 202 {"challenge":"totp_setup"} when TOTPOptions.Required makes a user without TOTP enroll first (POST {prefix}/totp/setup, then /totp/enable). Refusals are the JSON shape of auth.ErrorResponse with stable codes such as invalid_credentials and invalid_totp_code.
The provider never logs and never starts goroutines; it reports every request to the optional auth.Observer. It is safe for concurrent use.
Index ¶
- Variables
- func Hash(password string, parameters Parameters) (string, error)
- func Verify(password, encodedHash string) (bool, error)
- func VerifyBcrypt(password, storedHash string) (bool, error)
- type Challenge
- type MemoryStorage
- func (s *MemoryStorage) ClearTOTP(_ context.Context, id uuid.UUID) error
- func (s *MemoryStorage) ConsumeBackupCode(_ context.Context, id uuid.UUID, hash string) (bool, error)
- func (s *MemoryStorage) ConsumeTOTPAttempt(_ context.Context, id uuid.UUID, maximum int) (int, error)
- func (s *MemoryStorage) ConsumeTOTPStep(_ context.Context, id uuid.UUID, step time.Time) (bool, error)
- func (s *MemoryStorage) CreateUser(_ context.Context, user *User) error
- func (s *MemoryStorage) GetUserByEmail(_ context.Context, email string) (*User, error)
- func (s *MemoryStorage) GetUserByID(_ context.Context, id uuid.UUID) (*User, error)
- func (s *MemoryStorage) GetUserByUsername(_ context.Context, username string) (*User, error)
- func (s *MemoryStorage) ResetTOTPAttempts(_ context.Context, id uuid.UUID) error
- func (s *MemoryStorage) SetTOTP(_ context.Context, id uuid.UUID, secret string, backupCodeHashes []string) (bool, error)
- func (s *MemoryStorage) UpdatePasswordHash(_ context.Context, id uuid.UUID, currentHash, newHash string) error
- type Options
- type Parameters
- type Policy
- type Provider
- type Storage
- type TOTPOptions
- type User
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( ErrUserNotFound = errors.New("user not found") ErrUserExists = errors.New("user already exists") ErrTOTPAttemptsExhausted = errors.New("totp attempts exhausted") )
Storage contract errors. Storage implementations return them; the provider branches on them with errors.Is.
var ErrInvalidHash = errors.New("invalid password hash")
ErrInvalidHash reports a stored hash that is not an Argon2id hash Verify accepts: another format, another Argon2 version, malformed parts or parameters outside the limits.
Functions ¶
func Hash ¶
func Hash(password string, parameters Parameters) (string, error)
Hash returns the Argon2id hash of password in the encoding "$argon2id$v=19$m=..,t=..,p=..$salt$hash" (salt and hash base64 without padding). The password must have 1..128 bytes. It is safe for concurrent use.
func Verify ¶
Verify reports whether password matches encodedHash in constant time. Anything but an Argon2id hash within the limits (memory 128 MiB, 50 iterations, parallelism 8, salt and key 16..1024 bytes) is ErrInvalidHash. It is safe for concurrent use.
func VerifyBcrypt ¶
VerifyBcrypt is a ready-made Options.LegacyVerifier for bcrypt hashes (PocketBase, Django, Rails and many others): it reports whether password matches storedHash. A malformed hash or an unsupported cost is an error, not a mismatch, so a broken import shows. bcrypt compares only the first 72 bytes of a password; the upgrade to Argon2id after a match keys the whole password, which is the one the user types. It is safe for concurrent use.
Types ¶
type Challenge ¶
type Challenge string
Challenge is the second-factor step a login answers with 202 {"challenge": ...}.
type MemoryStorage ¶
type MemoryStorage struct {
// contains filtered or unexported fields
}
MemoryStorage is an in-memory Storage for tests and examples. It honours every Storage contract, matches usernames and emails case-insensitively and hands out copies, so callers never share its records. It is safe for concurrent use; the zero value is not usable, call NewMemoryStorage. ponytail: lookups by username and email scan every user; index them if it ever holds more than a test's worth.
func NewMemoryStorage ¶
func NewMemoryStorage() *MemoryStorage
NewMemoryStorage returns an empty MemoryStorage.
func (*MemoryStorage) ConsumeBackupCode ¶
func (s *MemoryStorage) ConsumeBackupCode(_ context.Context, id uuid.UUID, hash string) (bool, error)
ConsumeBackupCode removes hash and reports whether it was there.
func (*MemoryStorage) ConsumeTOTPAttempt ¶
func (s *MemoryStorage) ConsumeTOTPAttempt(_ context.Context, id uuid.UUID, maximum int) (int, error)
ConsumeTOTPAttempt reserves one attempt below maximum, else ErrTOTPAttemptsExhausted.
func (*MemoryStorage) ConsumeTOTPStep ¶
func (s *MemoryStorage) ConsumeTOTPStep(_ context.Context, id uuid.UUID, step time.Time) (bool, error)
ConsumeTOTPStep records step when it is later than the last one and reports whether it was.
func (*MemoryStorage) CreateUser ¶
func (s *MemoryStorage) CreateUser(_ context.Context, user *User) error
CreateUser stores a copy of user; a used ID, username or email is ErrUserExists.
func (*MemoryStorage) GetUserByEmail ¶
GetUserByEmail returns a copy of the user with email, or ErrUserNotFound.
func (*MemoryStorage) GetUserByID ¶
GetUserByID returns a copy of the user with id, or ErrUserNotFound.
func (*MemoryStorage) GetUserByUsername ¶
GetUserByUsername returns a copy of the user with username, or ErrUserNotFound.
func (*MemoryStorage) ResetTOTPAttempts ¶
ResetTOTPAttempts sets the attempt counter to zero.
func (*MemoryStorage) SetTOTP ¶
func (s *MemoryStorage) SetTOTP(_ context.Context, id uuid.UUID, secret string, backupCodeHashes []string) (bool, error)
SetTOTP enables TOTP and resets the attempt counter while TOTP is disabled and reports whether it did.
func (*MemoryStorage) UpdatePasswordHash ¶
func (s *MemoryStorage) UpdatePasswordHash(_ context.Context, id uuid.UUID, currentHash, newHash string) error
UpdatePasswordHash replaces the password hash while it still equals currentHash.
type Options ¶
type Options struct {
Sessions *session.Store // required
Storage Storage // required
PathPrefix string // default "/auth/password"; segments of A-Z a-z 0-9 . _ ~ -, no trailing "/", no "." or ".." segment
DisableUsernameLogin bool
DisableEmailLogin bool // both true: error
EnableRegistration bool // off by default: POST {prefix}/register is not even routed
Policy Policy
TOTP TOTPOptions
HashParameters Parameters // zero value: DefaultParameters(); at least 32 MiB, 1 iteration, salt and key 16 bytes
LegacyVerifier func(password, storedHash string) (bool, error) // optional, e.g. VerifyBcrypt; consulted for stored hashes that are not Argon2id
OnLogin auth.Hook // must keep UserID, the Storage user ID that Refresh and the TOTP routes look users up by; a login whose hook replaces it fails (500)
OnLogout auth.LogoutHook
Observer auth.Observer
}
Options configure New.
type Parameters ¶
type Parameters struct {
Memory uint32 // KiB
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
Parameters are Argon2id cost parameters.
func DefaultParameters ¶
func DefaultParameters() Parameters
DefaultParameters returns m=64 MiB, t=3, p=2, salt 16, key 32.
type Policy ¶
type Policy struct {
MinimumLength int // in characters (runes); default 12; maximum length is fixed at 128 bytes
RequireUppercase bool
RequireLowercase bool
RequireDigit bool
RequireSpecial bool // any rune that is neither letter nor digit
}
Policy is what a password chosen at registration must satisfy.
type Provider ¶
type Provider struct {
// contains filtered or unexported fields
}
Provider is the password login provider: JSON endpoints for login, an optional TOTP second factor, optional registration and logout, on users the application stores behind Storage. Its routes read the session themselves, so they need no Guard in front. It is safe for concurrent use.
func New ¶
New validates options, applies the defaults to a copy and precomputes the dummy hash used for unknown users (timing-safe lookups). It does not modify options, and changing them afterwards has no effect.
Example ¶
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/google/uuid"
"github.com/govengers/auth"
"github.com/govengers/auth/password"
"github.com/govengers/auth/session"
)
// newExampleSessions builds a session store on the in-process backend; production code shares a Redis
// backend between replicas and reads the key from its configuration.
func newExampleSessions() (*session.Store, error) {
backend, err := session.NewMemoryBackend(nil)
if err != nil {
return nil, err
}
key, err := session.ParseKey("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
if err != nil {
return nil, err
}
return session.NewStore(&session.StoreOptions{Backend: backend, EncryptionKey: key})
}
// newExampleStorage stands in for the application's user table with one user, ada.
func newExampleStorage() (*password.MemoryStorage, error) {
storage := password.NewMemoryStorage()
hash, err := password.Hash("correct horse battery staple", password.DefaultParameters())
if err != nil {
return nil, err
}
ada := &password.User{ID: uuid.MustParse("6f1c2a4e-8d3b-4f5a-9c7e-1b2d3e4f5a6b"), Username: new("ada"), PasswordHash: hash}
return storage, storage.CreateUser(context.Background(), ada)
}
// send serves request on handler with the cookies an earlier response set, as a browser would.
func send(handler http.Handler, request *http.Request, earlier *httptest.ResponseRecorder) *httptest.ResponseRecorder {
if earlier != nil {
for _, line := range earlier.Header().Values("Set-Cookie") {
if cookie, err := http.ParseSetCookie(line); err == nil {
request.AddCookie(cookie)
}
}
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder
}
func main() {
sessions, err := newExampleSessions()
if err != nil {
fmt.Println(err)
return
}
storage, err := newExampleStorage()
if err != nil {
fmt.Println(err)
return
}
provider, err := password.New(&password.Options{Sessions: sessions, Storage: storage})
if err != nil {
fmt.Println(err)
return
}
// The provider is the Guard's Refresher, so deleting a user ends their sessions within a minute.
guard, err := session.NewGuard(&session.GuardOptions{Store: sessions, Refreshers: []session.Refresher{provider}})
if err != nil {
fmt.Println(err)
return
}
serveMux := http.NewServeMux()
auth.RegisterRoutes(serveMux, provider.Routes())
serveMux.Handle("GET /auth/me", guard.RequireAPI()(auth.IdentityHandler()))
loginRequest := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/auth/password/login",
strings.NewReader(`{"identifier":"ada","password":"correct horse battery staple"}`))
loginRequest.Header.Set("Content-Type", "application/json")
login := send(serveMux, loginRequest, nil)
currentUser := send(serveMux, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/auth/me", nil), login)
fmt.Print(login.Code, " ", login.Body.String())
fmt.Print(currentUser.Code, " ", currentUser.Body.String())
}
Output: 200 {"provider":"password","subject":"6f1c2a4e-8d3b-4f5a-9c7e-1b2d3e4f5a6b","userId":"6f1c2a4e-8d3b-4f5a-9c7e-1b2d3e4f5a6b","username":"ada","active":true,"roles":[]} 200 {"provider":"password","subject":"6f1c2a4e-8d3b-4f5a-9c7e-1b2d3e4f5a6b","userId":"6f1c2a4e-8d3b-4f5a-9c7e-1b2d3e4f5a6b","username":"ada","active":true,"roles":[]}
func (*Provider) Name ¶
Name returns "password", the Identity.Provider of every identity this provider creates.
func (*Provider) Refresh ¶
func (p *Provider) Refresh(ctx context.Context, _ session.Session, identity *auth.Identity) (*auth.Identity, error)
Refresh implements session.Refresher: it re-reads the user by the identity's UserID (the Storage user ID, which OnLogin must keep) and updates Username and Email, keeping every other field. A deleted user is ErrUnauthenticated, which ends the session.
func (*Provider) Routes ¶
Routes returns the provider's endpoints below PathPrefix: login and logout always, register with EnableRegistration, the TOTP endpoints with TOTP.Enabled. Register them with auth.RegisterRoutes or ginadapter.RegisterRoutes.
Example ¶
package main
import (
"fmt"
"github.com/govengers/auth/password"
"github.com/govengers/auth/session"
)
// newExampleSessions builds a session store on the in-process backend; production code shares a Redis
// backend between replicas and reads the key from its configuration.
func newExampleSessions() (*session.Store, error) {
backend, err := session.NewMemoryBackend(nil)
if err != nil {
return nil, err
}
key, err := session.ParseKey("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
if err != nil {
return nil, err
}
return session.NewStore(&session.StoreOptions{Backend: backend, EncryptionKey: key})
}
func main() {
sessions, err := newExampleSessions()
if err != nil {
fmt.Println(err)
return
}
provider, err := password.New(&password.Options{
Sessions: sessions,
Storage: password.NewMemoryStorage(),
EnableRegistration: true,
TOTP: password.TOTPOptions{Enabled: true, Issuer: "Example"},
})
if err != nil {
fmt.Println(err)
return
}
for _, route := range provider.Routes() {
fmt.Println(route.Method, route.Path)
}
}
Output: POST /auth/password/login POST /auth/password/logout POST /auth/password/register POST /auth/password/totp/verify GET /auth/password/totp POST /auth/password/totp/setup POST /auth/password/totp/enable POST /auth/password/totp/disable
type Storage ¶
type Storage interface {
// CreateUser stores a new user. A username or email another user already has, ignoring case, is
// ErrUserExists; let a unique constraint decide it (e.g. on lower(email)), not a lookup before the insert.
CreateUser(ctx context.Context, user *User) error
// GetUserByUsername returns the user with username, ignoring case (e.g. WHERE lower(username) = lower($1)),
// or ErrUserNotFound. The provider passes identifiers as typed.
GetUserByUsername(ctx context.Context, username string) (*User, error)
// GetUserByEmail returns the user with email, ignoring case, or ErrUserNotFound.
GetUserByEmail(ctx context.Context, email string) (*User, error)
// GetUserByID returns the user with id, or ErrUserNotFound.
GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
// UpdatePasswordHash replaces the password hash only while the stored one still equals currentHash
// (compare-and-swap, e.g. UPDATE ... WHERE id = $1 AND password_hash = $2); a mismatch is not an error, so a
// concurrent password change is never overwritten.
UpdatePasswordHash(ctx context.Context, id uuid.UUID, currentHash, newHash string) error
// ConsumeTOTPAttempt reserves one second-factor attempt before a code is checked: below maximum it
// increments the counter and returns the attempts left after this one (never negative); otherwise it changes
// nothing and returns ErrTOTPAttemptsExhausted. Check and increment are one atomic step, e.g.
// UPDATE ... SET attempts = attempts + 1 WHERE id = $1 AND attempts < $2 RETURNING $2 - attempts.
ConsumeTOTPAttempt(ctx context.Context, id uuid.UUID, maximum int) (remaining int, err error)
// ResetTOTPAttempts sets the attempt counter to zero. The provider calls it after a verified second factor;
// the application calls it to unlock a user whose attempts are exhausted.
ResetTOTPAttempts(ctx context.Context, id uuid.UUID) error
// ConsumeTOTPStep marks the TOTP time step starting at step as the last one the user used: when step is later
// than the step it holds (or it holds none), it records step and returns true; otherwise it changes nothing
// and returns false, so a code is accepted once and never after a later one. Check and write are one atomic
// step, e.g. UPDATE ... SET totp_step = $2 WHERE id = $1 AND (totp_step IS NULL OR totp_step < $2).
// SetTOTP and ClearTOTP keep it.
ConsumeTOTPStep(ctx context.Context, id uuid.UUID, step time.Time) (bool, error)
// ConsumeBackupCode removes hash from BackupCodeHashes and reports whether it was there; of two concurrent
// calls with the same hash at most one returns true.
ConsumeBackupCode(ctx context.Context, id uuid.UUID, hash string) (bool, error)
// SetTOTP enables TOTP with secret and backupCodeHashes and resets the attempt counter, but only while TOTP is
// disabled: it reports whether it did, and changes nothing for a user who has TOTP enabled, so a concurrent
// enrollment never replaces the second factor another one just set. Check and write are one atomic step, e.g.
// UPDATE ... SET totp_enabled = true, ... WHERE id = $1 AND NOT totp_enabled.
SetTOTP(ctx context.Context, id uuid.UUID, secret string, backupCodeHashes []string) (bool, error)
// ClearTOTP disables TOTP, clears the secret and the backup codes and resets the attempt counter.
ClearTOTP(ctx context.Context, id uuid.UUID) error
}
Storage persists users; the application implements it. Every method is atomic on its own and safe for concurrent use. The TOTP state (TOTPEnabled, TOTPSecret, BackupCodeHashes, plus a failed-attempt counter and the last used time step the storage keeps) changes only through the TOTP methods below.
type TOTPOptions ¶
type TOTPOptions struct {
Enabled bool // routes the TOTP endpoints and challenges users who enabled TOTP; while off, their logins fail (500)
Required bool // users without TOTP enroll during login; needs Enabled
Issuer string // required when Enabled (otpauth issuer)
Period uint // default 30 s; at most 3600
Digits uint // 6 or 8; default 6
Skew uint // periods accepted before and after the current one; default 1; at most 10
MaximumAttempts int // default 5 wrong codes in a row, across logins; then the second factor stays locked until Storage.ResetTOTPAttempts
PendingTTL time.Duration // default 5 min (password step to code step)
DisableBackupCodes bool
BackupCodeCount int // default 10; at most 20
BackupCodeLength int // default 10; at most 128 bytes per code and at least a million distinct codes
BackupCodeAlphabet string // default "0123456789"; at least 2 distinct runes, no white space; stored lower-case, repeated runes count once
}
TOTPOptions configure the second factor.
type User ¶
type User struct {
ID uuid.UUID
Username *string // nil when the user has none; at least one of Username and Email is set
Email *string
PasswordHash string // Argon2id (Hash), or a legacy hash Options.LegacyVerifier understands
TOTPEnabled bool // set by SetTOTP, cleared by ClearTOTP
TOTPSecret *string // base32 TOTP secret while TOTPEnabled
BackupCodeHashes []string // Argon2id hashes of the unused backup codes
}
User is the record the provider reads from Storage. The library never deletes users and never writes profile fields; keep everything else about a user (creation time, roles, flags) in the application's own tables.