- 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>
288 lines
7.9 KiB
Go
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
|
|
}
|