293 lines
7.8 KiB
Go
293 lines
7.8 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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),
|
|
}
|
|
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
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|