Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
7.7 KiB
Go
282 lines
7.7 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)
|
|
}
|
|
|
|
// 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
|
|
}
|