Documentation
¶
Overview ¶
Package auth implements password hashing and verification, a multi-user password store loaded from a YAML file, the login prompts, multi-factor authentication via TOTP, and the Session type that tracks one connection's state for the rest of the program's life. It has no dependency on the command package, meaning a Command Level can exist and run without auth ever being wired in. This package only knows "is this the right password for this user" and "what does this session currently know about itself".
Three Separate, Deliberately Decoupled Layers ¶
It is easy to conflate "logging in" with "having elevated access". This framework deliberately keeps them separate, as three independent layers a project can use in any combination: a login prompt at the start of a session, a password on a Command Level, and a password on one specific command. A project can use any of these, all of them, or none of them, and none of the three requires the others to be configured.
Session.Authenticated tracks the first (login) system; Session.CommandLevel tracks the second.
Password Storage ¶
HashPassword and VerifyPassword are the only two functions that should ever touch the stored form of the password. Both work against the same `$id$encoded` format. This hashed password is currently generated by bcrypt. A project that later wants to support verifying legacy hashes from elsewhere would extend splitPasswordString's id handling, not change every call site that currently calls VerifyPassword.
Passwords are never stored, logged, or passed around as plaintext longer than the one call that needs them. PromptSecret reads a masked password directly in to a string that is handed straight to VerifyPassword and then allowed to go out of scope; there is no intermediate "logged in user's plaintext password" field anywhere in this package.
Users and the User Database ¶
A User entry in `etc/users.yaml` contains a username, a password hash, and optionally a TOTP secret for multi-factor login. LoadUsers parses and validates the whole file at startup (a user with no password hash at all is a hard error, not a silently-unusable account). SaveUsers is the inverse, writing the whole database back to disk under the same "users:" shape LoadUsers reads, so a running session, such as the totp enable and totp disable commands in package cmd, can persist a change made mid-session instead of requiring an administrator to hand edit the file and restart.
Logging In ¶
PromptLogin is the whole interactive flow. It reads a username, reads a masked password, verifies it, and, if the matched user has a second factor configured, immediately follows up with VerifySecondFactor before considering the session authenticated. It retries up to maxAttempts times, calling back auditFail after each wrong attempt so the caller can log it, and returns a fresh *Session on success.
Sessions ¶
Session is deliberately small, just enough state for the rest of the program to answer "who is this" and "what can they do right now" without re-deriving it on every command. See Session's own doc comment for exactly what each field means and who is responsible for keeping it current. The short version is that this package only ever sets Username/Authenticated, CommandLevel / CommandLevelEnteredAt are set by whichever hand-written cmd/cmd_*.go file calls command.EnterCommandLevel / ExitCommandLevel instead, which is why NewSession leaves CommandLevel as the zero value rather than trying to guess it.
Two-Factor Authentication (TOTP) ¶
totp.go implements TOTP (RFC 6238) from scratch. There are no external dependencies because the whole algorithm is small, well-specified, and worth being able to read end to end in one file rather than trusting a black box for something this security-sensitive. GenerateTOTPSecret creates a new random secret for a user being enrolled; TOTPProvisioningURI turns that in to the otpauth:// URI a phone authenticator app scans (as a QR code), and FormatTOTPSecretForDisplay groups that same secret for manual entry. VerifyTOTPCode checks a submitted code with a small clock-skew tolerance, the same way every real TOTP implementation does, since no two clocks agree to the second forever.
Enrollment itself has two entry points into the same underlying functions. main.go's --mfa flag drives it from the command line, before the CLI ever starts, requiring a relaunch to actually take effect. The user Command Level and its totp enable and totp disable commands, both in package cmd, drive the identical GenerateTOTPSecret, TOTPProvisioningURI, and VerifyTOTPCode calls from inside a running, already logged in session instead, through PromptTOTPCode below for reading the confirmation code and SaveUsers above for persisting the result, so enrolling or removing a second factor no longer requires stopping the program.
Index ¶
- Constants
- Variables
- func FormatTOTPSecretForDisplay(secret string) string
- func GenerateTOTPCode(base32Secret string, t time.Time) (string, error)
- func GenerateTOTPSecret() (string, error)
- func HashPassword(plaintext string) (string, error)
- func IsPlaintextHash(stored string) bool
- func PromptNewPassword(w io.Writer, fd int, t *i18n.Translator) (string, error)
- func PromptPasswordConfirmation(w io.Writer, fd int, t *i18n.Translator) (string, error)
- func PromptSecret(w io.Writer, fd int, t *i18n.Translator) (string, error)
- func PromptTOTPCode(w io.Writer, fd int, t *i18n.Translator) (string, error)
- func RoundForDisplay(d time.Duration) time.Duration
- func SaveUsers(path string, users Users) error
- func SecondFactorRequired(u *User) bool
- func TOTPProvisioningURI(issuer, username, base32Secret string) string
- func VerifyPassword(stored, candidate string) bool
- func VerifySecondFactor(w io.Writer, reader *bufio.Reader, fd int, u *User, t *i18n.Translator) bool
- func VerifySecondFactorCode(u *User, code string, now time.Time) bool
- func VerifyTOTPCode(base32Secret, code string, t time.Time) bool
- type KeyedRateLimiter
- type PasswordPolicy
- type PasswordViolation
- type RateLimiter
- type Session
- type User
- type Users
Constants ¶
const MaxPasswordLength = 72
MaxPasswordLength - This constant is the longest password HashPassword can actually hash, not a policy choice an operator can raise or lower. bcrypt, the algorithm HashPassword uses, silently ignores any byte past the 72nd in its input, so accepting a longer password here would let someone believe two different passwords both work when bcrypt itself only ever saw and checked their common 72-byte prefix. ValidatePassword rejects anything longer than this before it ever reaches HashPassword, so that mismatch can never happen.
Variables ¶
var ErrLoginFailed = errors.New("authentication failed")
ErrLoginFailed - This variable is returned by PromptLogin once the attempt limit is exhausted, so a caller such as main.go can distinguish a user typing the wrong password repeatedly from an actual I/O error, and choose an appropriate exit path and message for each.
Functions ¶
func FormatTOTPSecretForDisplay ¶
FormatTOTPSecretForDisplay - This function groups a raw base32 secret into four character blocks, the conventional grouping every authenticator app and setup guide uses, purely for human readability when typing it manually. VerifyTOTPCode and decodeTOTPSecret already strip spaces before decoding, so this grouping is display only and never affects what actually gets validated or stored. This is shared by both the standalone enrollment utility, main.go's --mfa flag, and the totp enable command in package cmd, so the two present a freshly generated secret exactly the same way.
func GenerateTOTPCode ¶
GenerateTOTPCode - This function computes the current six-digit code for a base32-encoded secret at time t. It is exposed mainly for the --mfa enrollment flow, which shows the administrator what code their app should be displaying right now, as a sanity check before they commit to typing one in. Most callers verifying a login attempt want VerifyTOTPCode instead, which also tolerates clock drift.
func GenerateTOTPSecret ¶
GenerateTOTPSecret - This function generates a new random TOTP secret, base32-encoded with no padding, the way every authenticator app expects it typed or scanned. It is called once per user during enrollment, see the --mfa flag in main.go. The result is what gets shown as both the QR code and the plain text manual entry string, and what an administrator pastes into that user's users.yaml entry as totp_secret.
func HashPassword ¶
HashPassword - This function hashes a plaintext password with bcrypt and returns it in the "$6$<encoded>" storage format used in etc/users.yaml.
func IsPlaintextHash ¶
IsPlaintextHash - This function reports whether the stored password is in the plaintext, "$0$...", storage format rather than a real hash. It returns false for anything that does not parse as a "$id$encoded" string.
func PromptNewPassword ¶
PromptNewPassword - This function reads a candidate new password, masked the same way PromptSecret reads an existing one, for cmd/cmd_password.go's password change command. It is a distinct function from PromptSecret, rather than PromptSecret reused as is, so its own prompt text ("New password: ") reads unambiguously different from a prompt for an already-known password.
func PromptPasswordConfirmation ¶
PromptPasswordConfirmation - This function reads a second, masked copy of a candidate new password, for cmd/cmd_password.go's password change command to confirm against what PromptNewPassword already read, the same "type it twice" confirmation step any password change form uses to catch a typo before it becomes the only copy of a password nobody, including its own owner, can actually reproduce.
func PromptSecret ¶
PromptSecret - This function reads a single password, masked, with no username and no association with any *User, and returns it as plaintext for the caller to verify.
func PromptTOTPCode ¶
PromptTOTPCode - This function reads a single six-digit TOTP code, masked the same as a password, with no *User association of its own and no verification, leaving that to the caller. This is the standalone counterpart to promptAndVerifyTOTP below, used by anything that already knows which secret to check a code against outside the login flow, such as the totp enable and totp disable commands in package cmd, rather than deriving that secret from a matched login attempt the way PromptLogin does. Unlike promptAndVerifyTOTP, this has no bufio.Reader fallback for a non-terminal fd, since every real caller of this function already runs inside main.go's interactive runLoop with a genuine terminal file descriptor, the same assumption PromptSecret in login.go already makes.
func RoundForDisplay ¶
RoundForDisplay - This function rounds a retry-after duration up to the nearest second before showing it to a user. The underlying duration often carries sub-second precision, for example "4m59.7s", that is meaningless noise in a "try again in %s" message. It is exported so every place that needs this can reach it.
func SaveUsers ¶
SaveUsers - This function writes users back to path, under the same single top-level "users:" key LoadUsers reads. This is what lets a running session, most notably the totp enable and totp disable commands in package cmd, persist a change made mid-session rather than requiring an administrator to hand edit the file and restart the program for it to take effect.
The write is atomic. A temporary file is written in the same directory as path and then renamed over it, so a process interrupted mid-write, or a full disk, never leaves a half-written, corrupt users file behind for the next startup to trip over.
This rewrites the whole file from users, every entry, not only whichever one changed. Any comments or formatting a hand edited users.yaml carried are not preserved, the same trade-off this project already accepts for its own generated configuration output elsewhere. Keeping the write unconditionally whole, rather than trying to patch one entry in place, keeps the file's shape simple and predictable.
func SecondFactorRequired ¶
SecondFactorRequired - This function reports whether u has any second factor configured, checked at login, see login.go's PromptLogin, right after the password verifies. This is the one place that needs to know about every second factor method that exists. Adding a new method later, such as FIDO2 or U2F, means adding its own "is it configured" check here and its own branch in VerifySecondFactor below, without touching PromptLogin or any other call site at all.
func TOTPProvisioningURI ¶
TOTPProvisioningURI - This function builds the standard "otpauth://" URI that every mainstream authenticator app, such as Google Authenticator, Authy, or 1Password, understands when scanned as a QR code. issuer is shown as the account's organization or service name in the app, for example "routercli". username identifies which account it is for. Encoding this correctly matters, URL escaping the label and using query parameters rather than hand-built string concatenation, because a malformed URI just silently fails to scan in most apps, with no useful error and no forgiving fallback if this is wrong.
func VerifyPassword ¶
VerifyPassword - This function checks a plaintext candidate against a stored "$id$encoded" hash, dispatching on id. An unrecognized id is treated as a verification failure rather than an error, since a corrupt or tampered users.yaml entry should deny access, not crash the process or, worse, silently let something through.
func VerifySecondFactor ¶
func VerifySecondFactor(w io.Writer, reader *bufio.Reader, fd int, u *User, t *i18n.Translator) bool
VerifySecondFactor - This function prompts for and checks whichever second factor u actually has configured. Only TOTP exists today. This function is the seam a future method, most likely FIDO2 or U2F, plugs into. See SecondFactorRequired's doc comment. It returns false, never true, if SecondFactorRequired(u) was false, which callers are expected to check first. This function does not re-derive whether the user needs a second factor at all, only whether the one they have configured checks out.
reader is the same *bufio.Reader that PromptLogin already wraps stdin in for reading the username. It is deliberately not a fresh io.Reader passed in separately, since wrapping the same underlying stream in a second, independent bufio.Reader risks losing bytes the first one already buffered ahead. fd is used only for the masked input path, since term.ReadPassword needs a real terminal file descriptor, not a Reader.
func VerifySecondFactorCode ¶
VerifySecondFactorCode - This function checks code against whichever second factor u actually has configured, given a code already read from wherever the caller got it, a masked terminal prompt, a test fixture, or otherwise. It performs no I/O of its own, the pure counterpart to VerifySecondFactor above, for a caller such as cmd/cmd_password.go's password change command that already runs its own retry loop around a masked prompt and only needs to check an already-read code, not have this function prompt for one itself. now is threaded through as a parameter rather than read with time.Now() internally, the same reason VerifyTOTPCode takes it, so a test can pass a fixed instant alongside a code generated for that same instant. It returns false, never true, if SecondFactorRequired(u) was false, mirroring VerifySecondFactor's own contract. See SecondFactorRequired's doc comment for how a future second factor method plugs into this same dispatch.
func VerifyTOTPCode ¶
VerifyTOTPCode - This function checks a user-entered code against a base32-encoded secret, tolerant of up to totpSkew time steps of clock drift in either direction. It uses a constant-time comparison for each candidate. A TOTP code is short-lived, but there is no reason to leak timing information about how close a guess was regardless.
Types ¶
type KeyedRateLimiter ¶
type KeyedRateLimiter struct {
// contains filtered or unexported fields
}
KeyedRateLimiter - This type is a RateLimiter per key, created lazily on first use. This exists for the one place rate limiting in this project needs to be scoped per identity rather than to one single shared resource, login, where locking out "alice" must not also lock out "bob". See PromptLogin. A single, shared RateLimiter across every username would let anyone lock out an arbitrary other user just by deliberately failing that user's password a few times, its own denial-of-service vector. Command Level and per-command password rate limiting, see command.EnterCommandLevel and main.go's runLoop, do not need this. A Command Level's or a command's own secret is a single shared resource, not per-user, so a plain *RateLimiter is enough there.
func NewKeyedRateLimiter ¶
func NewKeyedRateLimiter(maxAttempts int, window, lockout time.Duration) *KeyedRateLimiter
NewKeyedRateLimiter - This function constructs a KeyedRateLimiter. maxAttempts at or below zero disables rate limiting entirely, the same as RateLimiter.
func (*KeyedRateLimiter) Allow ¶
func (k *KeyedRateLimiter) Allow(key string) (ok bool, retryAfter time.Duration)
Allow - This method reports whether an attempt for key may proceed right now. See RateLimiter.Allow.
func (*KeyedRateLimiter) RecordFailure ¶
func (k *KeyedRateLimiter) RecordFailure(key string)
RecordFailure - This method records a failed attempt for key. See RateLimiter.RecordFailure.
func (*KeyedRateLimiter) RecordSuccess ¶
func (k *KeyedRateLimiter) RecordSuccess(key string)
RecordSuccess - This method clears key's failure history and any lockout. See RateLimiter.RecordSuccess.
type PasswordPolicy ¶
type PasswordPolicy struct {
MinLength int
RequireUppercase bool
RequireNumbers bool
RequireSpecialChars bool
}
PasswordPolicy - This type is the set of rules a new password must satisfy, checked by ValidatePassword. It mirrors config.SystemConfig's own Password* settings field for field, kept as a separate type here rather than importing package config directly, since package auth must not depend on package config, see the Core Library Versus Implementation split documented in PROGRESS.md for this project. A caller such as main.go builds one of these from the loaded SystemConfig and carries it on command.AppContext for cmd/cmd_password.go to use.
type PasswordViolation ¶
type PasswordViolation string
PasswordViolation - This type names one way a candidate password failed to satisfy a PasswordPolicy, or MaxPasswordLength, see ValidatePassword. It carries no message of its own, deliberately; package auth has no i18n awareness anywhere else either, see login.go's promptText, so a caller such as cmd/cmd_password.go maps each violation to its own translated message.
const ( PasswordViolationTooShort PasswordViolation = "too_short" PasswordViolationTooLong PasswordViolation = "too_long" PasswordViolationNeedsUppercase PasswordViolation = "needs_uppercase" PasswordViolationNeedsNumber PasswordViolation = "needs_number" PasswordViolationNeedsSpecialChar PasswordViolation = "needs_special_char" )
The complete set of PasswordViolation values ValidatePassword can return. TooShort and TooLong are checked unconditionally; the three composition violations only when the matching PasswordPolicy field requests them.
func ValidatePassword ¶
func ValidatePassword(candidate string, policy PasswordPolicy) []PasswordViolation
ValidatePassword - This function checks candidate against policy and the fixed MaxPasswordLength above, returning every rule it fails to satisfy, nil if it satisfies all of them. Every rule is checked and reported together, rather than stopping at the first failure, so a caller such as cmd/cmd_password.go can tell someone everything wrong with a rejected password at once instead of walking them through one violation per attempt.
Length is counted in runes, not bytes, so a password using multi-byte UTF-8 characters is measured the way a person actually counting characters on screen would, not penalized for using them. The one exception is MaxPasswordLength itself, checked in raw bytes, since that is genuinely what bcrypt's own limit counts.
This function performs no I/O and needs no *i18n.Translator, the same pure, dependency-free shape auth.VerifyTOTPCode and auth.VerifyPassword already have, so it can be unit tested directly against known inputs and reused by any future caller that needs to check a password without also prompting for one.
type RateLimiter ¶
type RateLimiter struct {
// contains filtered or unexported fields
}
RateLimiter - This type implements a sliding window attempt limiter with a lockout. After maxAttempts failures within window, further attempts are refused for lockout. This matches real Cisco's own "login block-for lockout-seconds attempts maxAttempts within window-seconds" directive, deliberately following that same shape, three numbers with the same relationship between them, rather than inventing new terminology, since it is the one most operators coming from real network gear will already recognize.
RateLimiter is safe for concurrent use, guarded by mu, since a CommandLevel's or Command's RateLimiter is shared state that could in principle be touched from more than one place, and package auth otherwise makes no assumptions about single-threaded callers.
Rate limiting is disabled entirely when maxAttempts is at or below zero. Allow then always returns true, and RecordFailure and RecordSuccess do nothing. This matches this project's existing convention for optional numeric settings, see config.SystemConfig's SessionIdleTimeout and ElevationTimeout, both disabled by zero, and means a project that does not set the *MaxAttempts configuration fields at all gets today's actual behavior, unlimited attempts, with no code change required to opt out.
now is an injectable clock, defaulting to time.Now through NewRateLimiter, so tests can advance time deterministically instead of calling time.Sleep for real. See ratelimit_test.go, which exercises window expiry and lockout expiry behavior in milliseconds of real wall time rather than minutes.
func NewRateLimiter ¶
func NewRateLimiter(maxAttempts int, window, lockout time.Duration) *RateLimiter
NewRateLimiter - This function constructs a RateLimiter. maxAttempts at or below zero disables rate limiting entirely. See RateLimiter's own doc comment.
func (*RateLimiter) Allow ¶
func (r *RateLimiter) Allow() (ok bool, retryAfter time.Duration)
Allow - This method reports whether an attempt may proceed right now. When locked out, ok is false and retryAfter is how much longer the lockout has to run, which callers use to build a "try again in %s" message rather than a bare refusal. Calling Allow does not itself count as an attempt. A caller checks Allow before prompting for a password, then calls RecordFailure or RecordSuccess based on the actual outcome. See EnterCommandLevel and main.go's runLoop for the two real call sites.
func (*RateLimiter) RecordFailure ¶
func (r *RateLimiter) RecordFailure()
RecordFailure - This method records one failed attempt. If this failure brings the count of failures within the last window up to maxAttempts, a lockout starting now and lasting for lockout is triggered, and the next Allow call, and every one until the lockout expires, refuses. Failures older than window are pruned lazily here, which is what makes this a sliding window rather than a fixed one. Three failures spread across an hour never trigger a lockout meant for three failures in two minutes.
func (*RateLimiter) RecordSuccess ¶
func (r *RateLimiter) RecordSuccess()
RecordSuccess - This method clears failure history and any active lockout. A successful login, elevation, or password check resets the counter entirely, matching how a real account lockout normally works. An account does not stay almost locked out forever just because someone once mistyped a password a few times before eventually getting it right.
type Session ¶
type Session struct {
Username string
Authenticated bool
CommandLevel string
CommandLevelEnteredAt time.Time
}
Session - This type tracks one CLI session's authentication state.
Username is empty until a successful login. It is used for audit log entries only. See Authenticated below for why nothing in this project gates command reachability on identity or login state.
Authenticated is true once the login prompt, or an equivalent caller, has verified a password. When AuthRequired is false in the tool configuration, main.go never runs the login prompt, and the session simply stays with Authenticated false for the whole session. This field is informational, used for audit log entries and for telling a real login apart from never having logged in. It does not, by itself, gate which commands a session can run. Command reachability is entirely a property of the Tree Structure, meaning which commands exist in which Command Level's own tree, and any password_hash a project chooses to set on a Command Level or an individual command, both completely decoupled from this field.
CommandLevel is the name of the command.CommandLevel this session is currently in. See command.TreeStructure and command.CommandLevel. It is set to the base level's Name at startup by main.go, since NewSession itself does not know the base level's name and so cannot set this. See NewSession's own doc comment. It is then updated by whichever hand-written cmd/cmd_*.go file calls command.EnterCommandLevel or command.ExitCommandLevel as a session moves between levels, for example cmd/cmd_enable.go. This field is only meaningful for root swap levels reached that way. A plain, nested mode such as config or config-if does not touch this field at all, and is tracked purely through Position, the CommandLevelStack, instead. See command.RequireCurrentCommandLevel's own doc comment for why the two are genuinely different axes. This field lives in package auth, not package command, so that package command, which already imports auth for other reasons, can depend on it without an import cycle. Session itself does not need to know what a CommandLevel actually is, only which one it is in, by name.
CommandLevelEnteredAt records when CommandLevel last changed. main.go's runLoop uses it together with the config.SystemConfig.ElevationTimeout setting to automatically revert to the base level once that much time has passed, the CLI equivalent of a privileged mode timeout. It is meaningless, and not read, while the session is at the base level.
func NewSession ¶
func NewSession() *Session
NewSession - This function returns an empty, unauthenticated session with CommandLevel left unset. The caller, main.go, is responsible for setting CommandLevel to the base CommandLevel's Name right after construction, since this function lives in package auth and has no knowledge of package command's CommandLevel concept at all. See the CommandLevel field's own doc comment above for why that split exists. Leaving CommandLevel at its zero value here, rather than threading a base level name through this constructor, keeps package auth decoupled from package command entirely. Nothing in this package needs to import command, and this function's signature never has to change if the tree structure system itself changes shape later.
func PromptLogin ¶
func PromptLogin(r io.Reader, w io.Writer, fd int, users Users, maxAttempts int, rateLimiter *KeyedRateLimiter, t *i18n.Translator, auditFail func(username string)) (*Session, error)
PromptLogin - This function drives the interactive login prompts for a username and password, reading the password with echo disabled. If the matched user has a second factor configured, a valid code for it is also required, read from the same bufio.Reader created here rather than a fresh one. A right password with a wrong or missing second factor code counts as a failed attempt, the same as a wrong password. It is reported and audited identically, so an attacker cannot distinguish a wrong password from a right password with the wrong TOTP code. auditFail is called after every failed attempt, not just the final one, so a caller can record each one rather than only the attempt that ended the session.
This function works even if the Translator is not set up.
When rateLimiter is nil, PromptLogin enforces a flat cap of maxAttempts total tries, with no windowing, lockout, or wait. When a rate limiter is supplied, the outer loop's own bound becomes a generous safety ceiling rather than the actual limiting mechanism. The rate limiter's own lockout, checked with Allow right after each username is read so it can be scoped per username, is what actually stops repeated attempts, and it does so without sleeping inline. Once a session is locked out, this function returns ErrLoginFailed immediately rather than blocking for the lockout duration, since a real, potentially minutes-long sleep inside an interactive prompt, or a scripted login flow, is worse than simply ending the attempt and telling the caller how long to wait before trying again.
func VerifyLogin ¶
VerifyLogin - This function is the actual login process, kept separate from any terminal I/O so it can be unit tested without a real tty. A nonexistent username and a wrong password intentionally produce the exact same result through the boolean return. Anything more specific, such as distinguishing "no such user" from "wrong password", would tell an attacker which usernames are valid, a classic login error message mistake.
func (*Session) AtLevel ¶
AtLevel - This method reports whether the session's current Command Level is exactly name. This is deliberately a comparison against an explicit name rather than a plain "Elevated" bool, since a tree can have more than one Command Level reachable from the base. Once there is more than one non-base level to elevate into, "is this session elevated" is no longer a yes-or-no question, while "is this session at this specific level" always has an unambiguous answer. buildPrompt's prompt suffix and the elevation timeout auto-revert in main.go's runLoop both call this against the base level's own Name.
type User ¶
type User struct {
Username string `yaml:"-"`
PasswordHash string `yaml:"password"`
TOTPSecret string `yaml:"totp_secret,omitempty"`
}
User - This type represents one entry in the user database.
type Users ¶
Users - This type is the in-memory form of the whole user database.
func LoadUsers ¶
LoadUsers - This function reads a user database from a YAML file at path. A user with an empty PasswordHash is a hard error at load time. An account nobody can ever log in to is almost certainly a mistake, not intent, and it is better to fail loudly at startup than have someone file a bug report about a login that just does not work.
Unknown YAML keys are also a hard error, the same way config.LoadSystemConfig treats them for its own configuration file. A misspelled field name in this file would otherwise be silently dropped rather than erroring, which is a worse mistake here than almost anywhere else in this project, since it would look like a secret was configured when it actually was not.