379 lines
10 KiB
Go
379 lines
10 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
|
|
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),
|
|
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")
|
|
}
|
|
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 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})
|
|
}
|