Files
steward/internal/auth/auth.go
T
abrown38andClaude Opus 5.5 8a69038ab5 Admin name at init, token rotation, read-only forms, token role presets
- stewardd init asks for the first administrator's name on a terminal.
- Tokens can be rotated (steward token rotate, POST /tokens/{id}/rotate,
  Rotate in the web UI), with a grace period for the old secret, as in
  Jarl. Schema migration 2 adds the token columns.
- Read-only users see the settings and a subnet's settings locked,
  instead of editable fields without a Save button.
- The New token dialog only offers roles within what the user may do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00

288 lines
7.9 KiB
Go

// Package auth handles passwords, API tokens, sessions and permissions.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"steward/internal/model"
)
// Argon2id parameters (OWASP: m=19 MiB, t=2, p=1).
const (
argonTime = 2
argonMemory = 19 * 1024
argonThreads = 1
argonKeyLen = 32
saltLen = 16
)
// MinPasswordLength is the shortest accepted password.
const MinPasswordLength = 8
// HashPassword returns an argon2id hash in PHC string format.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("a password needs at least %d characters", MinPasswordLength)
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// dummyHash is checked when a user doesn't exist, so a login takes as
// long whether or not the username is right.
var dummyHash, _ = HashPassword("not-a-real-password")
// CheckPassword reports whether password matches hash. An empty hash never
// matches (in the same time).
func CheckPassword(hash, password string) bool {
if hash == "" {
hash = dummyHash
password = "\x00" + password
}
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
for _, kv := range strings.Split(parts[3], ",") {
k, v, _ := strings.Cut(kv, "=")
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return false
}
switch k {
case "m":
m = uint32(n)
case "t":
t = uint32(n)
case "p":
p = uint8(n)
}
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
// RandomString returns a random string with nbytes of entropy.
func RandomString(nbytes int) string {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
// TokenPrefix starts every API token, so leaked ones are easy to spot.
const TokenPrefix = "steward_"
// NewToken makes a token: the whole string to give out, its public ID
// (stored in clear) and the hash of its secret.
func NewToken() (token, publicID, secretHash string) {
publicID = RandomString(10) // 16 characters
secret := RandomString(20) // 32 characters
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
}
// RotateToken makes a new secret for a token's public ID.
func RotateToken(publicID string) (token, secretHash string) {
secret := RandomString(20)
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
}
// ParseToken splits a token into its public ID and secret.
func ParseToken(token string) (publicID, secret string, err error) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
if !ok {
return "", "", errors.New("not a Steward token (they start with " + TokenPrefix + ")")
}
publicID, secret, ok = strings.Cut(rest, "_")
if !ok || len(publicID) != 16 || len(secret) != 32 {
return "", "", errors.New("malformed token")
}
return publicID, secret, nil
}
// HashSecret hashes a high-entropy secret (token secrets, session IDs).
func HashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])
}
// CheckSecret compares a secret with a stored hash in constant time.
func CheckSecret(secret, hash string) bool {
return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1
}
// NewSessionID returns a session ID and its CSRF token.
func NewSessionID() (id, csrf string) {
return RandomString(32), RandomString(32)
}
// Resources grants apply to.
const (
ResConfig = "config" // settings, subnets, reservations, history
ResLeases = "leases" // leases and DHCP activity
ResAudit = "audit" // the audit log
ResAll = "*"
)
// Resources lists them in display order.
var Resources = []string{ResConfig, ResLeases, ResAudit}
// Access levels.
const (
Read = "read"
Write = "write"
)
// Roles are ready-made sets of grants.
var Roles = map[string][]model.Grant{
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResLeases, Access: Read}},
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResLeases, Access: Write}, {Resource: ResAudit, Access: Read}},
"leases": {{Resource: ResLeases, Access: Read}},
}
// RoleNames lists the roles.
func RoleNames() []string {
var out []string
for n := range Roles {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ParseGrant reads "resource:access" ("leases:read", "*:write").
func ParseGrant(s string) (model.Grant, error) {
res, acc, ok := strings.Cut(strings.ToLower(strings.TrimSpace(s)), ":")
g := model.Grant{Resource: res, Access: acc}
if !ok {
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. leases:read", s)
}
return g, ValidateGrant(&g)
}
// ValidateGrant checks a grant.
func ValidateGrant(g *model.Grant) error {
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
ok := g.Resource == ResAll
for _, r := range Resources {
ok = ok || g.Resource == r
}
if !ok {
return fmt.Errorf("unknown resource %q (use %s or *)", g.Resource, strings.Join(Resources, ", "))
}
if g.Access != Read && g.Access != Write {
return fmt.Errorf("access must be read or write, not %q", g.Access)
}
return nil
}
// FormatGrant writes a grant as "resource:access".
func FormatGrant(g model.Grant) string { return g.Resource + ":" + g.Access }
// Principal is who makes a request.
type Principal struct {
User *model.User
Token *model.Token
Method string // session, token, socket
IP string
Agent string
}
// Name is the user's name.
func (p *Principal) Name() string {
if p == nil || p.User == nil {
return ""
}
return p.User.Username
}
// IsAdmin reports whether the principal has full rights: an
// administrator, not through a restricted token.
func (p *Principal) IsAdmin() bool {
return p.User != nil && p.User.Admin && (p.Token == nil || p.Token.Grants == nil)
}
// Can reports whether the principal may read (write false) or change
// (write true) a resource.
func (p *Principal) Can(resource string, write bool) bool {
if p == nil || p.User == nil {
return false
}
allowed := p.User.Admin || covers(p.User.Grants, resource, write)
if p.Token != nil && p.Token.Grants != nil {
allowed = allowed && covers(p.Token.Grants, resource, write)
}
return allowed
}
func covers(gs []model.Grant, resource string, write bool) bool {
for _, g := range gs {
if (g.Resource == ResAll || g.Resource == resource) && (!write || g.Access == Write) {
return true
}
}
return false
}
// Permissions lists what the principal may do, for display.
func (p *Principal) Permissions() []string {
var out []string
for _, r := range Resources {
switch {
case p.Can(r, true):
out = append(out, r+":write")
case p.Can(r, false):
out = append(out, r+":read")
}
}
if p.IsAdmin() {
out = append(out, "users:write")
}
return out
}
// Narrower reports whether every grant in a is covered by b.
func Narrower(a, b []model.Grant) bool {
for _, g := range a {
if !covers(b, g.Resource, g.Access == Write) {
if g.Resource != ResAll {
return false
}
for _, r := range Resources {
if !covers(b, r, g.Access == Write) {
return false
}
}
}
}
return true
}