- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
185 lines
5.8 KiB
Go
185 lines
5.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// MTLSConfig holds mTLS-specific configuration.
|
|
type MTLSConfig struct {
|
|
HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN")
|
|
HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify")
|
|
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
|
|
AutoActivate bool // auto-activate new users (default true)
|
|
DefaultTeam string // team ID for auto-provisioned users (optional)
|
|
DefaultRole string // "user" (default) or "admin"
|
|
}
|
|
|
|
// MTLSProvider authenticates via client certificate headers injected
|
|
// by the TLS-terminating reverse proxy (nginx, Traefik, Istio).
|
|
//
|
|
// The backend never sees the actual TLS handshake — it trusts headers
|
|
// injected by the proxy after cert validation. After reading and parsing
|
|
// the DN, the provider resolves an existing user or auto-provisions a
|
|
// new one, then returns a Result. The auth handler issues an internal JWT.
|
|
//
|
|
// Header flow:
|
|
//
|
|
// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN,
|
|
// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers.
|
|
type MTLSProvider struct {
|
|
cfg MTLSConfig
|
|
}
|
|
|
|
func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
|
|
if cfg.HeaderDN == "" {
|
|
cfg.HeaderDN = "X-SSL-Client-DN"
|
|
}
|
|
if cfg.HeaderVerify == "" {
|
|
cfg.HeaderVerify = "X-SSL-Client-Verify"
|
|
}
|
|
if cfg.HeaderFingerprint == "" {
|
|
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
|
|
}
|
|
if cfg.DefaultRole == "" {
|
|
cfg.DefaultRole = models.UserRoleUser
|
|
}
|
|
return &MTLSProvider{cfg: cfg}
|
|
}
|
|
|
|
func (p *MTLSProvider) Mode() Mode { return ModeMTLS }
|
|
|
|
func (p *MTLSProvider) SupportsRegistration() bool { return false }
|
|
|
|
func (p *MTLSProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
|
|
return nil, ErrNotSupported
|
|
}
|
|
|
|
// Authenticate reads the cert DN and verify status from proxy headers,
|
|
// parses the DN fields, and resolves or auto-provisions the user.
|
|
//
|
|
// Returns ErrInvalidCreds when headers are missing or verify fails.
|
|
// Returns ErrInactive when user exists but is deactivated.
|
|
func (p *MTLSProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
|
// ── Validate headers ───────────────────────────────────────────
|
|
verify := c.GetHeader(p.cfg.HeaderVerify)
|
|
if verify == "" {
|
|
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify)
|
|
}
|
|
// nginx: "SUCCESS", Traefik: "0" (both mean valid cert)
|
|
if verify != "SUCCESS" && verify != "0" {
|
|
return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify)
|
|
}
|
|
|
|
dn := c.GetHeader(p.cfg.HeaderDN)
|
|
if dn == "" {
|
|
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN)
|
|
}
|
|
|
|
fields := ParseDN(dn)
|
|
cn := fields["CN"]
|
|
if cn == "" {
|
|
return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds)
|
|
}
|
|
|
|
// Stable external identity: fingerprint if available, else full DN
|
|
fingerprint := c.GetHeader(p.cfg.HeaderFingerprint)
|
|
if fingerprint == "" {
|
|
fingerprint = dn
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// ── Look up existing user ──────────────────────────────────────
|
|
user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint)
|
|
if err == nil && user != nil {
|
|
if !user.IsActive {
|
|
return nil, ErrInactive
|
|
}
|
|
log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn)
|
|
return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil
|
|
}
|
|
|
|
// ── Auto-provision ─────────────────────────────────────────────
|
|
if !p.cfg.AutoActivate {
|
|
return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds)
|
|
}
|
|
|
|
return p.autoProvision(ctx, fields, fingerprint, stores)
|
|
}
|
|
|
|
func (p *MTLSProvider) autoProvision(
|
|
ctx context.Context,
|
|
dn map[string]string,
|
|
fingerprint string,
|
|
stores store.Stores,
|
|
) (*Result, error) {
|
|
cn := dn["CN"]
|
|
|
|
email := dn["emailAddress"]
|
|
if email == "" {
|
|
email = models.HandleFromName(cn) + "@mtls.local"
|
|
}
|
|
|
|
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn))
|
|
|
|
user := &models.User{
|
|
Username: handle,
|
|
Email: email,
|
|
DisplayName: cn,
|
|
Role: p.cfg.DefaultRole,
|
|
IsActive: true,
|
|
AuthSource: string(ModeMTLS),
|
|
ExternalID: &fingerprint,
|
|
Handle: handle,
|
|
}
|
|
|
|
if err := stores.Users.Create(ctx, user); err != nil {
|
|
return nil, fmt.Errorf("auto-provision failed: %w", err)
|
|
}
|
|
|
|
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
|
|
|
// Auto-add to default team if configured
|
|
if p.cfg.DefaultTeam != "" {
|
|
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
|
|
log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err)
|
|
}
|
|
}
|
|
|
|
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
|
|
}
|
|
|
|
// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs.
|
|
//
|
|
// Examples:
|
|
//
|
|
// "CN=Jeff Smith,O=Acme Corp,OU=Engineering"
|
|
// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp"
|
|
//
|
|
// Handles simple comma-separated key=value pairs. Does NOT handle
|
|
// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient
|
|
// for typical X.509 client cert DNs.
|
|
func ParseDN(dn string) map[string]string {
|
|
result := make(map[string]string)
|
|
parts := strings.Split(dn, ",")
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
idx := strings.Index(part, "=")
|
|
if idx < 1 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(part[:idx])
|
|
val := strings.TrimSpace(part[idx+1:])
|
|
result[key] = val
|
|
}
|
|
return result
|
|
}
|