initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,291 @@
|
||||
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:
|
||||
//
|
||||
// - Every comparison is constant-time, including the API key scan. A
|
||||
// byte-at-a-time comparison leaks a valid key prefix to anyone who can
|
||||
// measure response latency, which is a real attack on a network-exposed
|
||||
// admin API.
|
||||
// - Keys are compared by hash, not by string, so the stored form is not a
|
||||
// raw key and a timing difference between a long and short key cannot
|
||||
// reveal length.
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user