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, and neither is written against any one hashing algorithm directly. See PasswordHasher, in auth.go, for the interface a project implements to add or swap the algorithm HashPassword and VerifyPassword actually use. RouterCLI ships bcrypt as the default, registered and made active at package init, but a project wanting a different algorithm, one already required by an existing deployment's own compliance rules, or one believed to hold up against a future, quantum capable attacker, calls RegisterPasswordHasher with its own implementation and, if it should be used for every new hash from then on, SetDefaultPasswordHasher, without touching HashPassword, VerifyPassword, or any of their own call sites anywhere else in this project. Every password already hashed under a different algorithm keeps verifying correctly regardless of which one is currently the default, since VerifyPassword always dispatches on the id already embedded in the stored value.
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_*.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 is entirely self service, from inside a running, already logged in session. The user Command Level and its totp enable and totp disable commands, both in package core (cmd/core), drive GenerateTOTPSecret, TOTPProvisioningURI, and VerifyTOTPCode, through PromptTOTPCode below for reading the confirmation code and SaveUsers above for persisting the result. A user with no totp_secret set yet logs in with a password alone, then runs totp enable to add a second factor to their own account, with nothing to stop and relaunch. An earlier command line flag, --mfa, drove the identical functions from outside a running session, before this in-session path existed; it is removed as of Phase 29, now that totp enable fully covers what it was for.
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 IsRecognizedHash(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 RegisterPasswordHasher(h PasswordHasher)
- func RoundForDisplay(d time.Duration) time.Duration
- func SaveUsers(path string, users Users) error
- func SecondFactorRequired(u *User) bool
- func SetDefaultPasswordHasher(cryptID string) error
- 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 AuthProvider
- type KeyedRateLimiter
- type LocalAuthProvider
- type PasswordHasher
- 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 totp enable and totp enable qr, the two enrollment commands in package core (cmd/core), so both 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 tests that need a live code to confirm a freshly generated secret with, the same sanity check totp enable's own interactive confirmation step performs against whatever the user actually types. 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 totp enable and totp enable qr commands in package core (cmd/core). The result is what gets shown as both the QR code and the plain text manual entry string, and what SaveUsers persists into that user's users.yaml entry as totp_secret.
func HashPassword ¶
HashPassword - This function hashes a plaintext password with whichever PasswordHasher is currently the default, bcrypt unless a project has called SetDefaultPasswordHasher, and returns it in the "$<id>$<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 IsRecognizedHash ¶
IsRecognizedHash - This function reports whether stored is shaped like a real "$id$encoded" password hash whose id is currently registered with some PasswordHasher, see RegisterPasswordHasher, without attempting to verify it against anything. This exists for a caller accepting an already-hashed secret directly, rather than hashing a plaintext candidate itself, "password manager hash <hash>" in cmd/core/cmd_password_manager.go for instance, restoring a previously recorded secret from saved configuration text rather than a live, freshly typed password. Such a caller has nothing to verify the value against yet, there is no plaintext candidate in play at all, so VerifyPassword cannot be used to sanity check it, but accepting any arbitrary string unchecked would let an obvious mistake, a plaintext password typed into a field meant for an already-hashed value for instance, through silently. This is deliberately not the same check as IsPlaintextHash above: the plaintext form is itself a recognized, registered id, see plaintextHasher's own CryptID, so a value in that form still reports true here. Callers that specifically want to reject the plaintext form too, on top of this check, call IsPlaintextHash themselves as well.
func PromptNewPassword ¶
PromptNewPassword - This function reads a candidate new password, masked the same way PromptSecret reads an existing one, for cmd/core/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/core/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 core (cmd/core), 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 RegisterPasswordHasher ¶
func RegisterPasswordHasher(h PasswordHasher)
RegisterPasswordHasher - This function adds h to the set VerifyPassword can dispatch to, keyed by h.CryptID(). Calling this a second time for an id that is already registered replaces the previous entry, which lets a project override RouterCLI's own shipped bcryptHasher, a different cost for instance, by registering a new one under the same "6" id, though registering a different algorithm entirely under a new, unused id is the more common reason to call this.
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 core (cmd/core), 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 SetDefaultPasswordHasher ¶
SetDefaultPasswordHasher - This function changes which registered PasswordHasher HashPassword uses to produce a brand new hash from here on. It returns an error, and changes nothing, if cryptID has not been registered through RegisterPasswordHasher first. This has no effect on verifying any password already hashed under a different id; VerifyPassword always dispatches on the id already embedded in the stored value, never on whichever hasher is currently the default.
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 to whichever PasswordHasher is registered for id, see RegisterPasswordHasher. An unrecognized id, whether from a corrupt or tampered stored value or from a hash produced by an algorithm no longer registered, is treated as a verification failure rather than an error, since that 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/core/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 AuthProvider ¶
AuthProvider - This type is the seam every backend that can check a typed username and password plugs into: today only LocalAuthProvider, bcrypt hashes in a Users database, with an LDAP or a RADIUS backend the kind of thing expected to implement this same interface later without VerifyLogin, PromptLogin, or cmd/core/cmd_password.go's own reauthentication step needing to change at all. See config.SystemConfig.AuthProviders for how a deployment names which backend, of which kind, it wants, and NewAuthProvider for how a name there becomes a real value of this type.
Authenticate reports whether password is correct for username. A non-nil error means the check itself could not be completed, a network failure reaching a remote directory for instance, distinct from ok being false for a password that was actually wrong. A caller such as VerifyLogin treats either an error or ok being false as a failed login, exactly the same as it always has, since neither case should ever let a session through.
func NewAuthProvider ¶
func NewAuthProvider(providerType string, users Users) (AuthProvider, error)
NewAuthProvider - This function builds the AuthProvider a config.SystemConfig.AuthProviders entry's Type names, "local" being the only recognized value today. An unrecognized Type is an error rather than something silently ignored, the same fail loudly convention every other malformed setting in this project follows, since a typo'd Type would otherwise mean a deployment believes it is checking passwords against a backend that does not actually exist. users is only meaningful for the "local" Type; a future Type, an LDAP or a RADIUS backend for instance, would take whatever connection details its own config.AuthProviderConfig fields eventually carry instead.
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 LocalAuthProvider ¶
type LocalAuthProvider struct {
Users Users
}
LocalAuthProvider - This type is the AuthProvider backed by this project's own etc/users.yaml, or whatever a project renames its own UsersFile to, checking a candidate password against the matching User's own PasswordHash with VerifyPassword. This is what every deployment used before AuthProvider existed at all, so it stays the default entry in config.DefaultSystemConfig's own AuthProviders list, keeping every existing deployment's behavior unchanged.
func NewLocalAuthProvider ¶
func NewLocalAuthProvider(users Users) *LocalAuthProvider
NewLocalAuthProvider - This function constructs a LocalAuthProvider checking candidate passwords against users.
func (*LocalAuthProvider) Authenticate ¶
func (p *LocalAuthProvider) Authenticate(username, password string) (bool, error)
Authenticate - This method implements AuthProvider for LocalAuthProvider. A nonexistent username still runs a real comparison, through whichever PasswordHasher is currently the default, see PasswordHasher.Dummy in auth.go, before returning, the same timing side channel defense VerifyLogin has always performed, now living here since this is the one place that actually knows whether a username exists in this backend's own Users map. Going through the default hasher rather than calling bcrypt directly is what keeps this defense correct even after a project calls SetDefaultPasswordHasher to move away from bcrypt entirely; a fixed bcrypt comparison here would burn the wrong amount of CPU time once real logins are being checked against a different algorithm, quietly reopening the exact timing side channel this exists to close.
type PasswordHasher ¶
type PasswordHasher interface {
// CryptID returns this hasher's own storage format identifier,
// the "id" segment of a stored "$id$encoded" password. This must
// be stable for the life of a deployment; changing it for an
// algorithm already in use would strand every password already
// hashed under the old id.
CryptID() string
// Hash returns plaintext hashed and encoded for storage, the
// part that goes after "$id$". It does not include the "$id$"
// prefix itself; HashPassword adds that.
Hash(plaintext string) (string, error)
// Verify reports whether candidate matches encoded, this
// hasher's own encoded form with the surrounding "$id$" already
// stripped off.
Verify(encoded, candidate string) bool
// Dummy returns a fixed, valid encoded value, in this hasher's
// own format, suitable for feeding back into Verify purely to
// burn the same amount of CPU time a real comparison would. This
// is what LocalAuthProvider.Authenticate, see provider.go, calls
// through the currently active default hasher when a username
// does not exist, so that timing alone never reveals whether a
// username exists at all. The plaintext this decodes to, if it
// decodes to anything meaningful, is never compared against
// anything a real user could type.
Dummy() string
}
PasswordHasher is one algorithm capable of producing and checking a password's stored, encoded form. RouterCLI ships bcrypt as the default, see bcryptHasher below, but nothing in this package, or in anything that calls HashPassword or VerifyPassword, is written against bcrypt specifically. A project wanting a different algorithm, one believed to hold up against a future, quantum capable attacker for instance, or one already required by an existing deployment's own compliance rules, implements this interface, calls RegisterPasswordHasher once at startup, and optionally calls SetDefaultPasswordHasher to make HashPassword use it for every new hash from then on. Every existing stored hash, in whatever algorithm produced it, keeps verifying correctly regardless of which algorithm is currently the default, since VerifyPassword always dispatches on the id already embedded in the stored value, never on whatever HashPassword would use for a brand new one.
func NewBcryptHasher ¶
func NewBcryptHasher(cost int) PasswordHasher
NewBcryptHasher - This function returns RouterCLI's own shipped PasswordHasher, bcrypt at the given cost. RegisterPasswordHasher is called with cost bcryptCost automatically at package init; a project wanting a different cost calls this directly and registers the result itself, under the same "6" id to replace the default outright, or under a new id to offer both side by side.
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/core/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/core/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/core/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
HostUsername string
HostConnectedAt 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_*.go file calls command.EnterCommandLevel or command.ExitCommandLevel as a session moves between levels, for example cmd/core/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.
HostUsername and HostConnectedAt are set only when config.SystemConfig.EnableHostAuthentication trusted an operating system account identity to reach this session, see SessionFromHostIdentity. HostUsername is that account's own name, which is not necessarily Username: when EnableCLIAuthentication is also on, reached over a shared account for instance, Username ends up being whichever identity the CLI login itself resolved to, while HostUsername stays the underlying OS account the connection actually arrived as. HostConnectedAt is when that OS identity was established, which can meaningfully predate Username being set at all, if a slow or repeatedly failed CLI login followed it. Both are their zero values, an empty string and a zero time.Time, when EnableHostAuthentication was never in play for this session, and main.go's audit log entry for a new session includes both only in that case. See main.go's establishSession.
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, provider AuthProvider, users Users, totpEnabled bool, 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, checking the typed password with provider, see AuthProvider. users is still needed alongside provider, since a resolved identity's own second factor secret, and the TOTP prompt that goes with it below, is always looked up in this project's own users.yaml database regardless of which AuthProvider actually checked the password; an AuthProvider such as a future LDAP or RADIUS backend has no notion of a TOTP secret of its own. A username provider authenticates that has no matching entry in users, possible once a non-local AuthProvider is in play, is treated as a real identity with no second factor configured rather than an error, see the u == nil guard below.
totpEnabled mirrors config.SystemConfig.EnableTOTPAuthentication. When false, no second factor is ever requested here, even for a user whose users.yaml entry has a TOTPSecret set, since that global switch is meant to turn step-up authentication off deployment wide, not merely to stop new second factors from being enrolled. See cmd/core/cmd_totp.go, which is removed from the tree entirely, through command's Requires field, when this same configuration flag is off.
If the matched user has a second factor configured and totpEnabled is true, 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 SessionFromHostIdentity ¶
SessionFromHostIdentity - This function builds an already authenticated Session directly from the operating system account routercli itself is running as, read through os/user.Current, with no password prompted for or checked at all. This exists for config.SystemConfig.EnableHostAuthentication, meant for a deployment reached over SSH where sshd already authenticated the underlying Unix account, whether routercli is installed as that account's own login shell or reached through a ForceCommand, before routercli ever started. Trusting that identity here is sound specifically because it is not client controlled: the operating system itself decided which account this process is running as, before this function, or any other part of routercli, ever ran.
The returned Session carries the same account name on both Username and HostUsername, and the current instant on HostConnectedAt. A caller that also runs its own CLI login on top of this, see main.go's establishSession for when EnableCLIAuthentication is also on, is expected to overwrite Username with whatever that login resolves to while keeping HostUsername and HostConnectedAt as they are here, since those two describe how the connection arrived, not who is now identified as using it.
func VerifyLogin ¶
func VerifyLogin(provider AuthProvider, username, password string) (*Session, bool)
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. It delegates the actual credential check to provider, see AuthProvider, so this function itself no longer needs to know whether that check is against a local users.yaml, an LDAP directory, or anything else. 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, and AuthProvider's own implementations, see LocalAuthProvider.Authenticate, are written to preserve that property.
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"`
Roles []string `yaml:"roles,omitempty"`
MustChangePassword bool `yaml:"must_change_password,omitempty"`
}
User - This type represents one entry in the user database.
Roles is the set of role names, see command.Role and command.RoleSet in package command, this account has been assigned, checked by command.Authorized against a Command or CommandLevel's own AllowedRoles list. This is empty for every account until an administrator, from inside the new admin Command Level, runs "account roles add", see cmd/core/cmd_admin.go. package auth itself has no notion of what a role actually gates; it only carries the names.
MustChangePassword, false by default, forces a session logging in as this account straight into the password change flow, before anything else runs, the moment login succeeds, see main.go's own call to core.RunPasswordChange right after establishSession. This is set whenever "account create", see cmd/core/cmd_admin.go, either prompted for this account's first password interactively or generated one, never when a pre-computed hash was imported instead, since an imported hash is presumed to already be the real intended credential. A successful password change, forced or voluntary, always clears this, see cmd/core/cmd_password.go's finishPasswordChange.
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.