Refactor project layout
All checks were successful
Release / release (push) Successful in 1m38s

This commit is contained in:
2026-04-01 13:16:06 +02:00
parent 5d2a80bd30
commit a0a7932f99
14 changed files with 796 additions and 447 deletions

111
pkg/auth/htpasswd.go Normal file
View File

@@ -0,0 +1,111 @@
package auth
import (
"bufio"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"fmt"
"os"
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
)
// HtpasswdAuth authenticates users against an Apache-compatible htpasswd file.
//
// Supported hash formats:
// - bcrypt ($2y$, $2a$, $2b$) — recommended; generate with: htpasswd -B -c file user
// - SHA-1 ({SHA}...) — legacy only; use bcrypt for new entries
//
// The file is parsed at startup and can be reloaded at runtime by calling Reload()
// (wired to SIGHUP in main). Lines beginning with '#' and blank lines are ignored.
type HtpasswdAuth struct {
path string
mu sync.RWMutex
entries map[string]string // username → hash
}
// NewHtpasswdAuth reads path and returns a ready HtpasswdAuth.
func NewHtpasswdAuth(path string) (*HtpasswdAuth, error) {
h := &HtpasswdAuth{path: path}
if err := h.Reload(); err != nil {
return nil, err
}
return h, nil
}
// Reload re-reads the htpasswd file from disk, atomically swapping the
// in-memory table. Safe to call from a signal handler goroutine.
func (h *HtpasswdAuth) Reload() error {
f, err := os.Open(h.path)
if err != nil {
return fmt.Errorf("opening htpasswd %s: %w", h.path, err)
}
defer f.Close()
entries := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
user, hash, ok := strings.Cut(line, ":")
if !ok {
continue
}
entries[user] = hash
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("reading htpasswd: %w", err)
}
h.mu.Lock()
h.entries = entries
h.mu.Unlock()
return nil
}
// Len returns the number of users currently loaded.
func (h *HtpasswdAuth) Len() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.entries)
}
// Authenticate returns nil if username/password match an entry in the file.
func (h *HtpasswdAuth) Authenticate(username, password string) error {
h.mu.RLock()
hash, ok := h.entries[username]
h.mu.RUnlock()
if !ok {
return fmt.Errorf("invalid credentials")
}
return verifyHTPasswd(hash, password)
}
func verifyHTPasswd(hash, password string) error {
switch {
case strings.HasPrefix(hash, "$2y$"),
strings.HasPrefix(hash, "$2a$"),
strings.HasPrefix(hash, "$2b$"):
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return fmt.Errorf("invalid credentials")
}
return nil
case strings.HasPrefix(hash, "{SHA}"):
sum := sha1.Sum([]byte(password))
expected := "{SHA}" + base64.StdEncoding.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(hash), []byte(expected)) == 1 {
return nil
}
return fmt.Errorf("invalid credentials")
default:
return fmt.Errorf("unsupported htpasswd hash format (use bcrypt: htpasswd -B file user)")
}
}

81
pkg/auth/kerberos.go Normal file
View File

@@ -0,0 +1,81 @@
package auth
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
goidentity "github.com/jcmturner/goidentity/v6"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/service"
"github.com/jcmturner/gokrb5/v8/spnego"
)
// KerberosAuth validates Kerberos SPNEGO tokens using a service keytab.
//
// The client must obtain a service ticket for the SPN (default: HTTP/<fqdn>@REALM)
// and present it via the standard "Authorization: Negotiate <base64>" header.
// Any GSSAPI-aware client works: curl --negotiate, kinit + curl, python-requests-gssapi, etc.
type KerberosAuth struct {
kt *keytab.Keytab
spn string // e.g. "HTTP/k3s.example.com" — empty = auto-select from ticket SName
}
// NewKerberosAuth loads the keytab from keytabPath.
// spn is written into the service settings so gokrb5 validates the correct principal;
// pass an empty string to accept any principal present in the keytab.
func NewKerberosAuth(keytabPath, spn string) (*KerberosAuth, error) {
kt, err := keytab.Load(keytabPath)
if err != nil {
return nil, fmt.Errorf("loading keytab %s: %w", keytabPath, err)
}
return &KerberosAuth{kt: kt, spn: spn}, nil
}
// Authenticate validates the SPNEGO token from the request's Authorization header.
// On success it returns the authenticated username (principal primary, without @REALM).
// On failure it sets a WWW-Authenticate: Negotiate header on w (for mutual-auth
// continuation tokens) and returns an error; the caller is responsible for the 401.
func (k *KerberosAuth) Authenticate(w http.ResponseWriter, r *http.Request) (string, error) {
if !strings.HasPrefix(r.Header.Get("Authorization"), "Negotiate ") {
return "", fmt.Errorf("no Negotiate token in request")
}
// We use gokrb5's SPNEGO middleware but call it synchronously via a
// closure — ServeHTTP is not goroutine-concurrent so the captured
// variables are safe to read after the call returns.
var (
authed bool
username string
)
inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
id := goidentity.FromHTTPRequestContext(r)
if id != nil && id.Authenticated() {
authed = true
username = id.UserName()
}
})
var opts []func(*service.Settings)
if k.spn != "" {
opts = append(opts, service.KeytabPrincipal(k.spn))
}
opts = append(opts, service.DecodePAC(false))
opts = append(opts, service.Logger(log.Default()))
// Route through a recorder so we can capture the WWW-Authenticate
// continuation token (mutual authentication) without writing it yet.
rec := httptest.NewRecorder()
spnego.SPNEGOKRB5Authenticate(inner, k.kt, opts...).ServeHTTP(rec, r)
if !authed {
// Forward any Negotiate continuation token from the middleware.
if v := rec.Header().Get("WWW-Authenticate"); v != "" {
w.Header().Set("WWW-Authenticate", v)
}
return "", fmt.Errorf("kerberos authentication failed")
}
return username, nil
}

289
pkg/auth/ldap.go Normal file
View File

@@ -0,0 +1,289 @@
package auth
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/url"
"strconv"
"strings"
"github.com/go-ldap/ldap/v3"
)
// LDAPAuth authenticates users against an LDAP directory discovered via DNS SRV.
type LDAPAuth struct {
domain string
host string
port int
useTLS bool // true = LDAPS (TLS from the start); false = STARTTLS on port 389
bindDN string // empty = anonymous bind
bindPassword string
log *log.Logger
}
// NewLDAPAuth discovers the LDAP server for domain via the standard _ldap._tcp
// DNS SRV record. The connection mode is derived from the advertised port:
// port 389 uses STARTTLS; anything else (typically 636) uses LDAPS.
// _ldaps._tcp is not an IANA-registered SRV type and is not consulted.
// bindDN and bindPassword are used for the search bind; both empty = anonymous.
func NewLDAPAuth(domain, bindDN, bindPassword string, dbg *log.Logger) (*LDAPAuth, error) {
host, port, err := discoverLDAP(domain)
if err != nil {
return nil, err
}
return &LDAPAuth{
domain: domain,
host: host,
port: port,
useTLS: port != 389,
bindDN: bindDN,
bindPassword: bindPassword,
log: dbg,
}, nil
}
// NewLDAPAuthFromURI creates an LDAPAuth from an explicit URI instead of DNS
// SRV discovery. Supported schemes: ldap:// (STARTTLS) and ldaps:// (TLS).
// Port defaults to 389 for ldap:// and 636 for ldaps:// if not specified.
// domain is still required for base-DN derivation and UPN construction.
// bindDN and bindPassword are used for the search bind; both empty = anonymous.
func NewLDAPAuthFromURI(rawURI, domain, bindDN, bindPassword string, dbg *log.Logger) (*LDAPAuth, error) {
u, err := url.Parse(rawURI)
if err != nil {
return nil, fmt.Errorf("invalid LDAP URI %q: %w", rawURI, err)
}
var useTLS bool
var defaultPort int
switch strings.ToLower(u.Scheme) {
case "ldap":
useTLS = false
defaultPort = 389
case "ldaps":
useTLS = true
defaultPort = 636
default:
return nil, fmt.Errorf("unsupported LDAP URI scheme %q (want ldap:// or ldaps://)", u.Scheme)
}
host := u.Hostname()
port := defaultPort
if ps := u.Port(); ps != "" {
port, err = strconv.Atoi(ps)
if err != nil {
return nil, fmt.Errorf("invalid port in LDAP URI %q: %w", rawURI, err)
}
}
return &LDAPAuth{
domain: domain,
host: host,
port: port,
useTLS: useTLS,
bindDN: bindDN,
bindPassword: bindPassword,
log: dbg,
}, nil
}
// Host returns the LDAP server hostname (used for logging).
func (a *LDAPAuth) Host() string { return a.host }
// Port returns the LDAP server port (used for logging).
func (a *LDAPAuth) Port() int { return a.port }
// UseTLS reports whether TLS is in use (used for logging).
func (a *LDAPAuth) UseTLS() bool { return a.useTLS }
func discoverLDAP(domain string) (host string, port int, err error) {
_, addrs, err := net.LookupSRV("ldap", "tcp", domain)
if err != nil || len(addrs) == 0 {
return "", 0, fmt.Errorf("no _ldap._tcp SRV records found for %s", domain)
}
return strings.TrimSuffix(addrs[0].Target, "."), int(addrs[0].Port), nil
}
func (a *LDAPAuth) connect() (*ldap.Conn, error) {
addr := fmt.Sprintf("%s:%d", a.host, a.port)
tlsConfig := &tls.Config{ServerName: a.host}
if a.useTLS {
a.log.Printf("LDAP: dialing TLS %s", addr)
return ldap.DialTLS("tcp", addr, tlsConfig)
}
a.log.Printf("LDAP: dialing %s + STARTTLS", addr)
conn, err := ldap.Dial("tcp", addr)
if err != nil {
return nil, err
}
if err := conn.StartTLS(tlsConfig); err != nil {
conn.Close()
return nil, fmt.Errorf("STARTTLS: %w", err)
}
return conn, nil
}
// searchBind binds with the configured service account, or anonymously if no
// bind DN is configured.
func (a *LDAPAuth) searchBind(conn *ldap.Conn) error {
if a.bindDN == "" {
return conn.UnauthenticatedBind("")
}
return conn.Bind(a.bindDN, a.bindPassword)
}
// findUserDN searches for the user entry and returns its full DN.
func (a *LDAPAuth) findUserDN(conn *ldap.Conn, username string) (string, error) {
search := ldap.NewSearchRequest(
domainToBaseDN(a.domain),
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false,
fmt.Sprintf("(|(sAMAccountName=%s)(uid=%s))",
ldap.EscapeFilter(username),
ldap.EscapeFilter(username)),
[]string{"dn"},
nil,
)
result, err := conn.Search(search)
if err != nil || len(result.Entries) == 0 {
return "", fmt.Errorf("user not found")
}
return result.Entries[0].DN, nil
}
// Authenticate verifies username/password against LDAP and returns the user's
// group CNs (used as Kubernetes RBAC groups via the certificate's Organisation field).
// Returns a generic error on bad credentials to avoid user-enumeration.
//
// Flow: search bind → find user DN → user bind (verify password) → search bind → group lookup.
func (a *LDAPAuth) Authenticate(username, password string) (groups []string, err error) {
if username == "" || password == "" {
return nil, fmt.Errorf("username and password required")
}
conn, err := a.connect()
if err != nil {
return nil, fmt.Errorf("LDAP connect: %w", err)
}
defer conn.Close()
// Bind as service account (or anonymously) to locate the user's DN.
if err := a.searchBind(conn); err != nil {
return nil, fmt.Errorf("LDAP search bind failed: %w", err)
}
a.log.Printf("LDAP: searching for user %q", username)
userDN, err := a.findUserDN(conn, username)
if err != nil {
return nil, fmt.Errorf("invalid credentials")
}
// Verify the password by binding as the user.
a.log.Printf("LDAP: binding as %s", userDN)
if err := conn.Bind(userDN, password); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
a.log.Printf("LDAP: bind OK for %s", userDN)
// Re-bind as service account for group lookup — the user may lack read access.
if err := a.searchBind(conn); err != nil {
a.log.Printf("LDAP: re-bind for group lookup failed: %v — skipping groups", err)
return nil, nil // auth succeeded; group lookup is best-effort
}
groups = a.lookupGroups(conn, username, userDN)
a.log.Printf("LDAP: groups for %s: %v", username, groups)
return groups, nil
}
// lookupGroups searches for group memberships using two strategies so it works
// with both Active Directory (memberOf attribute) and POSIX/OpenLDAP layouts
// (groupOfNames / posixGroup with member or memberUid attributes).
func (a *LDAPAuth) lookupGroups(conn *ldap.Conn, username, userDN string) []string {
baseDN := domainToBaseDN(a.domain)
var groups []string
// AD-style: memberOf attribute on the user's own entry.
memberOfSearch := ldap.NewSearchRequest(
userDN,
ldap.ScopeBaseObject, ldap.NeverDerefAliases,
1, 10, false,
"(objectClass=*)",
[]string{"memberOf"},
nil,
)
if result, err := conn.Search(memberOfSearch); err == nil && len(result.Entries) > 0 {
for _, groupDN := range result.Entries[0].GetAttributeValues("memberOf") {
if cn := cnFromDN(groupDN); cn != "" {
groups = append(groups, cn)
}
}
}
// POSIX/OpenLDAP style: search for groups that list this user as a member.
groupSearch := ldap.NewSearchRequest(
baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
0, 10, false,
fmt.Sprintf("(|(member=%s)(memberUid=%s))",
ldap.EscapeFilter(userDN),
ldap.EscapeFilter(username)),
[]string{"cn"},
nil,
)
if groupResult, err := conn.Search(groupSearch); err == nil {
for _, entry := range groupResult.Entries {
if cn := entry.GetAttributeValue("cn"); cn != "" && !containsStr(groups, cn) {
groups = append(groups, cn)
}
}
}
return groups
}
// LookupGroups searches for the user's groups using the configured bind credentials
// (or anonymously). Used after Kerberos authentication to populate Kubernetes RBAC
// group memberships.
func (a *LDAPAuth) LookupGroups(username string) []string {
conn, err := a.connect()
if err != nil {
return nil
}
defer conn.Close()
if err := a.searchBind(conn); err != nil {
return nil
}
userDN, err := a.findUserDN(conn, username)
if err != nil {
return nil
}
return a.lookupGroups(conn, username, userDN)
}
// domainToBaseDN converts "example.com" to "dc=example,dc=com".
func domainToBaseDN(domain string) string {
parts := strings.Split(domain, ".")
dcs := make([]string, len(parts))
for i, p := range parts {
dcs[i] = "dc=" + p
}
return strings.Join(dcs, ",")
}
// cnFromDN extracts the CN value from the first CN= component of an LDAP DN.
func cnFromDN(dn string) string {
for _, part := range strings.Split(dn, ",") {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(part)), "cn=") {
return strings.TrimSpace(part)[3:]
}
}
return ""
}
func containsStr(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}