180 lines
5.7 KiB
Go
180 lines
5.7 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/ed25519"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"sultan/pkg/store"
|
||
)
|
||
|
||
var (
|
||
// ErrUnauthenticated: Signatur fehlt, ist falsch oder der Schlüssel ist
|
||
// nicht registriert. Bewusst ohne Unterscheidung nach außen.
|
||
ErrUnauthenticated = errors.New("auth: nicht authentifiziert")
|
||
// ErrClockSkew: Der Zeitstempel liegt außerhalb des erlaubten Fensters.
|
||
ErrClockSkew = errors.New("auth: Zeitstempel außerhalb des Toleranzfensters")
|
||
// ErrReplay: Die Nonce wurde bereits verwendet.
|
||
ErrReplay = errors.New("auth: Request wiederholt (Replay)")
|
||
// ErrForbidden: Authentifiziert, aber gesperrt (Mandant in Quarantäne).
|
||
ErrForbidden = errors.New("auth: Zugriff gesperrt")
|
||
// ErrBodyTooLarge: Der Body überschreitet MaxBodySize.
|
||
ErrBodyTooLarge = errors.New("auth: Body zu groß")
|
||
)
|
||
|
||
// Directory schlägt Knoten und Mandanten nach; *store.Store erfüllt es.
|
||
type Directory interface {
|
||
GetNodeByPublicKey(ctx context.Context, publicKey string) (store.Node, error)
|
||
GetTenant(ctx context.Context, id string) (store.Tenant, error)
|
||
}
|
||
|
||
// Identity ist der geprüfte Absender eines Requests.
|
||
type Identity struct {
|
||
Node store.Node
|
||
Tenant store.Tenant
|
||
}
|
||
|
||
// Verifier prüft signierte Requests auf Server-Seite. Der Nullwert (plus
|
||
// Directory) ist einsatzbereit; er darf parallel verwendet werden.
|
||
type Verifier struct {
|
||
Directory Directory
|
||
// MaxSkew ist die erlaubte Zeitabweichung zwischen Agent und Server (Default 5min).
|
||
MaxSkew time.Duration
|
||
// MaxBodySize begrenzt den signierten Body (Default 1 MiB).
|
||
MaxBodySize int64
|
||
|
||
now func() time.Time
|
||
nonces nonceCache
|
||
}
|
||
|
||
func (v *Verifier) maxSkew() time.Duration {
|
||
if v.MaxSkew > 0 {
|
||
return v.MaxSkew
|
||
}
|
||
return 5 * time.Minute
|
||
}
|
||
|
||
func (v *Verifier) maxBodySize() int64 {
|
||
if v.MaxBodySize > 0 {
|
||
return v.MaxBodySize
|
||
}
|
||
return 1 << 20
|
||
}
|
||
|
||
func (v *Verifier) clock() time.Time {
|
||
if v.now != nil {
|
||
return v.now()
|
||
}
|
||
return time.Now()
|
||
}
|
||
|
||
// Verify prüft r und liefert die Identität des registrierten Knotens.
|
||
// Prüfreihenfolge: TLS, Signatur (ohne Datenbank), Zeitfenster, Nonce,
|
||
// erst dann der Store – ungültige Requests kosten also keinen DB-Zugriff
|
||
// und können keine Nonces verbrauchen.
|
||
//
|
||
// Gesperrt wird nur bei Quarantäne (ErrForbidden). Zahlungsverzug, Ablauf
|
||
// und gedrainte Knoten regelt die Policy-Engine über das Routing; der Agent
|
||
// braucht den Kanal weiterhin, z. B. für lease_renew.
|
||
func (v *Verifier) Verify(r *http.Request) (Identity, error) {
|
||
key, err := v.check(r)
|
||
if err != nil {
|
||
return Identity{}, err
|
||
}
|
||
node, err := v.Directory.GetNodeByPublicKey(r.Context(), key)
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
return Identity{}, fmt.Errorf("%w: Schlüssel nicht registriert", ErrUnauthenticated)
|
||
}
|
||
if err != nil {
|
||
return Identity{}, fmt.Errorf("auth: Knoten nachschlagen: %w", err)
|
||
}
|
||
tenant, err := v.Directory.GetTenant(r.Context(), node.TenantID)
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
return Identity{}, fmt.Errorf("%w: Mandant fehlt", ErrUnauthenticated)
|
||
}
|
||
if err != nil {
|
||
return Identity{}, fmt.Errorf("auth: Mandant nachschlagen: %w", err)
|
||
}
|
||
if tenant.Status == store.TenantQuarantined {
|
||
return Identity{}, fmt.Errorf("%w: Mandant %s in Quarantäne", ErrForbidden, tenant.ID)
|
||
}
|
||
return Identity{Node: node, Tenant: tenant}, nil
|
||
}
|
||
|
||
// VerifyUnregistered prüft einen signierten Request eines noch nicht
|
||
// registrierten Schlüssels (Enrollment). Bewiesen ist damit nur der Besitz
|
||
// des privaten Schlüssels; Token und Mandant prüft store.Enroll.
|
||
func (v *Verifier) VerifyUnregistered(r *http.Request) (ed25519.PublicKey, error) {
|
||
key, err := v.check(r)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return DecodePublicKey(key)
|
||
}
|
||
|
||
// check prüft alles außer der Registrierung und liefert den kanonisch
|
||
// kodierten Public Key.
|
||
func (v *Verifier) check(r *http.Request) (string, error) {
|
||
if r.TLS == nil {
|
||
return "", ErrInsecureTransport
|
||
}
|
||
pub, err := DecodePublicKey(r.Header.Get(HeaderKey))
|
||
if err != nil {
|
||
return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderKey)
|
||
}
|
||
tsRaw := r.Header.Get(HeaderTimestamp)
|
||
ts, err := strconv.ParseInt(tsRaw, 10, 64)
|
||
if err != nil {
|
||
return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderTimestamp)
|
||
}
|
||
nonce := r.Header.Get(HeaderNonce)
|
||
if len(nonce) < 16 || len(nonce) > 64 {
|
||
return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderNonce)
|
||
}
|
||
sig, err := base64.StdEncoding.DecodeString(r.Header.Get(HeaderSignature))
|
||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||
return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderSignature)
|
||
}
|
||
body, err := readBody(r, v.maxBodySize())
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !ed25519.Verify(pub, canonical(r.Method, r.URL.RequestURI(), tsRaw, nonce, body), sig) {
|
||
return "", fmt.Errorf("%w: Signatur ungültig", ErrUnauthenticated)
|
||
}
|
||
|
||
now := v.clock()
|
||
signedAt := time.Unix(ts, 0)
|
||
if d := now.Sub(signedAt); d > v.maxSkew() || d < -v.maxSkew() {
|
||
return "", fmt.Errorf("%w: Abweichung %v", ErrClockSkew, d.Round(time.Second))
|
||
}
|
||
key := EncodePublicKey(pub)
|
||
if !v.nonces.add(key, nonce, signedAt.Add(v.maxSkew()), now) {
|
||
return "", ErrReplay
|
||
}
|
||
return key, nil
|
||
}
|
||
|
||
// StatusCode bildet Fehler von Verify auf HTTP-Statuscodes ab.
|
||
func StatusCode(err error) int {
|
||
switch {
|
||
case err == nil:
|
||
return http.StatusOK
|
||
case errors.Is(err, ErrInsecureTransport):
|
||
return http.StatusUpgradeRequired
|
||
case errors.Is(err, ErrBodyTooLarge):
|
||
return http.StatusRequestEntityTooLarge
|
||
case errors.Is(err, ErrForbidden):
|
||
return http.StatusForbidden
|
||
case errors.Is(err, ErrUnauthenticated), errors.Is(err, ErrClockSkew), errors.Is(err, ErrReplay):
|
||
return http.StatusUnauthorized
|
||
default:
|
||
return http.StatusInternalServerError
|
||
}
|
||
}
|