owpengram-server/cmd/telesrv-admin/adminauth.go

154 lines
5.5 KiB
Go

package main
import (
"context"
"errors"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
// bcryptCost is deliberately above bcrypt.DefaultCost (10). A panel login is a
// once-per-shift operation, so the extra time is invisible to an operator and
// meaningful to anyone working through a stolen dump of the table.
const bcryptCost = 12
// dummyBcryptHash is compared against when no account matched, so a login
// attempt costs the same whether or not the username exists. Without it the
// response time alone answers "is there an operator called X" -- the exact
// question the uniform error message refuses to answer.
//
// Value is bcrypt of a random string at bcryptCost; nothing authenticates
// against it.
const dummyBcryptHash = "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// breakGlassUsername is the name of the built-in operator backed by
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than by a database row.
//
// It is a real name rather than "no name" so audit lines read as an operator
// instead of as a blank, and so signing in as it is an explicit act: a blank
// username authenticates nothing.
//
// A database account may not take this name: authenticateLogin resolves it to
// the environment credential before ever consulting the table, so a row called
// "owpengram" would be shadowed -- and a name that silently does nothing is a
// trap. createAdminConsoleUser rejects it outright.
const breakGlassUsername = "owpengram"
// loginIdentity is who a successful login turns out to be.
type loginIdentity struct {
actor string
userID int64
epoch int32
permissions []string
}
// authenticateLogin resolves a login request to an identity, or reports
// failure. It never distinguishes its failure modes to the caller: every one
// of them is a plain false, so the handler cannot accidentally leak which.
func (s *server) authenticateLogin(ctx context.Context, req loginRequest) (loginIdentity, bool) {
username := strings.TrimSpace(req.Username)
// A username is always required. An empty one used to resolve to the
// break-glass operator, which made a blank field an unnamed second route to
// the highest-privilege login -- the sort of thing that does not belong in
// an admin panel. The operator must now be asked for by name.
if username == "" {
return loginIdentity{}, false
}
// The break-glass operator. Intentionally not backed by the database so it
// still works when the database does not.
if strings.EqualFold(username, breakGlassUsername) {
if !s.validSecret(req.Secret) {
return loginIdentity{}, false
}
return loginIdentity{actor: breakGlassUsername, permissions: s.cfg.Permissions}, true
}
if s.read == nil {
return loginIdentity{}, false
}
cred, err := s.read.AdminConsoleCredentialByUsername(ctx, username)
if err != nil {
if !errors.Is(err, errAdminUserNotFound) {
return loginIdentity{}, false
}
// Burn the same work an existing account would have cost before
// answering, so "no such user" and "wrong password" take equal time.
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(req.Secret))
return loginIdentity{}, false
}
if bcrypt.CompareHashAndPassword([]byte(cred.PasswordHash), []byte(req.Secret)) != nil {
return loginIdentity{}, false
}
// Checked after the hash comparison on purpose: answering "disabled"
// faster than "wrong password" would confirm the account exists to someone
// who does not know its password.
if !cred.Enabled {
return loginIdentity{}, false
}
return loginIdentity{
actor: cred.Username,
userID: cred.ID,
epoch: cred.TokenEpoch,
permissions: cred.Permissions,
}, true
}
// hashAdminPassword validates a new password and returns its bcrypt hash.
func hashAdminPassword(password string) (string, error) {
if err := validateAdminPassword(password); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// validateAdminPassword deliberately imposes no length floor and no
// composition rule: the operator picks the password.
//
// The two checks that remain are not policy. A blank password is not a weak
// password, it is no password -- anyone who learns the username is in. And
// bcrypt silently ignores everything past 72 bytes, so a longer one is refused
// rather than quietly truncated to something the operator did not choose and
// cannot reproduce.
func validateAdminPassword(password string) error {
if strings.TrimSpace(password) == "" {
return errPasswordBlank
}
if len([]byte(password)) > 72 {
return errPasswordTooLong
}
return nil
}
var (
errPasswordTooLong = errors.New("password must be at most 72 bytes")
errPasswordBlank = errors.New("password must not be blank")
errUsernameInvalid = errors.New("username must be 3-64 characters: letters, digits, dot, dash or underscore")
)
// validateAdminUsername keeps usernames to a shape that reads the same
// everywhere it is displayed. Anything outside it -- spaces, control
// characters, look-alike unicode -- is refused rather than normalised, since a
// username that renders differently from what is stored is a way to be
// mistaken for another operator.
func validateAdminUsername(username string) error {
if n := len([]rune(username)); n < 3 || n > 64 {
return errUsernameInvalid
}
for _, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', unicode.IsDigit(r):
case r == '.', r == '-', r == '_':
default:
return errUsernameInvalid
}
}
return nil
}