Files
fancy-dns/internal/api/auth.go
T
mats a3d1614dca
build / build-push (push) Successful in 1m8s
rework
2026-10-16 11:13:13 +02:00

389 lines
11 KiB
Go

package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net/http"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
)
// authenticator checks API keys, basic credentials and issued session tokens.
//
// Two details matter for security here:
//
// - Credentials are never compared in plaintext. An API key is hashed with
// SHA-256 and looked up in a map keyed on that hash, so the long-lived
// structure holds no key that could be read back out of a core dump, and a
// lookup is a hash-map hit whose cost does not depend on how much of the
// key the caller guessed. There is no byte-at-a-time scan: the comment that
// used to describe one described a function with no callers.
// - The two fields that are compared directly — the basic username and the
// hashed password — go through subtle.ConstantTimeCompare.
type authenticator struct {
mode string
// readOnly maps key hash -> key label.
readOnly map[string]string
// readWrite maps key hash -> key label.
readWrite map[string]string
username string
// passwordHash is the sha256 of the configured password, so plaintext
// passwords in a config file are never compared byte-by-byte.
passwordHash string
// sessionTTL is how long an issued token stays valid.
sessionTTL time.Duration
// trustLoopback is informational; the caller checks it.
mu sync.RWMutex
sessions map[string]session
oidcPending map[string]oidcLogin
oidcRedirectURI string
oidcIssuer string
oidcAudience string
oidcClientID string
oidcWriteRoles map[string]bool
oidcInitMu sync.Mutex
oidcVerifier *oidc.IDTokenVerifier
}
func newAuthenticator(cfg config.Auth) *authenticator {
a := &authenticator{
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
oidcRedirectURI: cfg.OIDCRedirectURI,
oidcIssuer: cfg.OIDCIssuer,
oidcAudience: cfg.OIDCAudience,
oidcClientID: cfg.OIDCClientID,
oidcWriteRoles: make(map[string]bool),
}
for _, role := range cfg.OIDCWriteRoles {
a.oidcWriteRoles[role] = true
}
if len(a.oidcWriteRoles) == 0 {
a.oidcWriteRoles["admin"] = true
}
for i, k := range cfg.ReadOnlyAPIKeys {
if k == "" {
continue
}
a.readOnly[hashKey(k)] = keyLabel("ro", i)
}
for i, k := range cfg.APIKeys {
if k == "" {
continue
}
a.readWrite[hashKey(k)] = keyLabel("rw", i)
}
if cfg.Password != "" {
// A bcrypt-looking value is already a hash; a plaintext value is
// hashed so comparisons stay constant-time either way.
a.passwordHash = hashKey(cfg.Password)
}
if a.sessionTTL <= 0 {
a.sessionTTL = 12 * time.Hour
}
return a
}
// oidcPrincipal verifies an RS256 access token against issuer discovery/JWKS.
// Provider metadata and keys are fetched lazily, then cached by go-oidc.
func (a *authenticator) oidcPrincipal(raw string) (Principal, error) {
if a == nil || a.oidcIssuer == "" || a.oidcAudience == "" {
return Principal{}, errors.New("oidc: not configured")
}
a.oidcInitMu.Lock()
if a.oidcVerifier == nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
provider, err := oidc.NewProvider(ctx, a.oidcIssuer)
cancel()
if err != nil {
a.oidcInitMu.Unlock()
return Principal{}, fmt.Errorf("oidc discovery: %w", err)
}
a.oidcVerifier = provider.Verifier(&oidc.Config{ClientID: a.oidcAudience, SupportedSigningAlgs: []string{"RS256"}})
}
verifier := a.oidcVerifier
a.oidcInitMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tok, err := verifier.Verify(ctx, raw)
if err != nil {
return Principal{}, fmt.Errorf("oidc token: %w", err)
}
var claims struct {
Subject string `json:"sub"`
TokenUse string `json:"token_use"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
if err := tok.Claims(&claims); err != nil {
return Principal{}, err
}
if claims.Subject == "" || claims.TokenUse != "access" {
return Principal{}, errors.New("oidc: token is not an access token")
}
name := claims.Name
if name == "" {
name = claims.Email
}
if name == "" {
name = claims.Subject
}
readOnly := true
for _, role := range claims.Roles {
if a.oidcWriteRoles[role] {
readOnly = false
break
}
}
return Principal{Mode: "oidc", Name: name, ReadOnly: readOnly}, nil
}
func (a *authenticator) OIDCConfig() (issuer, clientID string) {
if a == nil || a.mode != "oidc" {
return "", ""
}
return a.oidcIssuer, a.oidcClientID
}
func keyLabel(kind string, i int) string {
return kind + "-" + itoa(i+1)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// hashKey returns the hex sha256 of a credential.
func hashKey(k string) string {
sum := sha256.Sum256([]byte(k))
return hex.EncodeToString(sum[:])
}
// apiKey resolves an API key to a principal.
func (a *authenticator) apiKey(key string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
h := hashKey(key)
if label, ok := a.readWrite[h]; ok {
return Principal{Mode: "apikey", Name: label}, nil
}
if label, ok := a.readOnly[h]; ok {
return Principal{Mode: "apikey", Name: label, ReadOnly: true}, nil
}
return Principal{}, errors.New("invalid API key")
}
// basic resolves basic credentials and, on success, issues a session token.
func (a *authenticator) basic(user, pass string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
switch a.mode {
case "basic":
if subtle.ConstantTimeCompare([]byte(user), []byte(a.username)) != 1 {
return Principal{}, errors.New("invalid username")
}
if len(a.passwordHash) == 0 {
return Principal{}, errors.New("no password configured")
}
if subtle.ConstantTimeCompare([]byte(hashKey(pass)), []byte(a.passwordHash)) != 1 {
return Principal{}, errors.New("invalid password")
}
return Principal{Mode: "basic", Name: user}, nil
case "apikey":
// Basic auth with an API key in the password field is a common client
// convention (curl -u any:KEY).
return a.apiKey(pass)
default:
return Principal{}, errors.New("basic auth is not enabled")
}
}
// session validates a bearer token.
func (a *authenticator) session(token string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
a.mu.RLock()
s, ok := a.sessions[token]
a.mu.RUnlock()
if !ok {
return Principal{}, errors.New("invalid or expired session")
}
if time.Now().After(s.expires) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
return Principal{}, errors.New("session expired")
}
if s.accessToken != "" {
return a.oidcPrincipal(s.accessToken)
}
return s.principal, nil
}
// issue creates a session token for a principal.
func (a *authenticator) issue(p Principal) (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is fatal for a token; fall back to a token that
// will not authenticate rather than issuing something predictable.
return "", time.Time{}
}
token := hex.EncodeToString(b)
exp := time.Now().Add(a.sessionTTL)
a.mu.Lock()
a.sessions[token] = session{principal: p, expires: exp}
// Opportunistic expiry sweep: sessions are few, so a linear pass is fine.
now := time.Now()
for k, v := range a.sessions {
if now.After(v.expires) {
delete(a.sessions, k)
}
}
a.mu.Unlock()
return token, exp
}
// revoke drops a session token.
func (a *authenticator) revoke(token string) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
// Config reports the auth mode for the UI.
func (a *authenticator) Config() string {
if a == nil {
return "none"
}
return a.mode
}
// AuthMode returns the configured mode.
func (s *Server) AuthMode() string { return s.auth.Config() }
// handleLogin authenticates a login attempt and returns a session token.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
var in struct {
APIKey string `json:"api_key"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
var (
p Principal
err error
)
switch {
case in.APIKey != "":
p, err = s.auth.apiKey(in.APIKey)
case in.Username != "":
p, err = s.auth.basic(in.Username, in.Password)
default:
writeError(w, http.StatusBadRequest, "api_key or username/password required")
return
}
if err != nil {
s.recordLoginFailure(ip)
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.clearLoginFailures(ip)
token, exp := s.auth.issue(p)
if token == "" {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
s.log.Info("admin login", "user", p.Name, "mode", p.Mode, "client", ip)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"token": token,
"expires_at": exp,
"name": p.Name,
"read_only": p.ReadOnly,
"mode": p.Mode,
})
}
// handleLogout drops the caller's session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(browserSessionCookie); err == nil {
s.auth.revoke(c.Value)
}
s.setBrowserCookie(w, browserSessionCookie, "", -1)
if h := r.Header.Get("Authorization"); h != "" {
if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" {
s.auth.revoke(value)
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func cut(s, sep string) (before, after string, found bool) {
i := indexOf(s, sep)
if i < 0 {
return s, "", false
}
return s[:i], s[i+len(sep):], true
}
func indexOf(s, sub string) int {
if len(sub) > len(s) {
return -1
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// handleWhoami reports the caller's identity, which the UI uses to decide
// whether to show write controls.
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) {
p, _ := PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"name": p.Name,
"mode": p.Mode,
"read_only": p.ReadOnly,
"can_write": p.CanWrite(),
"auth_mode": s.AuthMode(),
"base_path": s.base,
})
}
func (s *Server) handleAuthConfig(w http.ResponseWriter, r *http.Request) {
issuer, clientID := s.auth.OIDCConfig()
writeJSON(w, http.StatusOK, map[string]string{"mode": s.AuthMode(), "issuer": issuer, "client_id": clientID})
}