Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

381 lines
11 KiB
Go

// Package auth implements login, JWT session issuance/validation, and logout,
// ported from archivmail's internal/auth pattern. LDAP and TOTP are
// intentionally left out of this initial scaffold; they can be re-added later
// following the same shape archivmail uses (Manager.SetTenantLDAP etc.).
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"archivdms/internal/audit"
"archivdms/internal/ldapauth"
"archivdms/internal/ldapstore"
"archivdms/internal/tenantstore"
"archivdms/internal/userstore"
)
// Session holds the claims extracted from a validated JWT.
type Session struct {
UserID int64
Username string
Email string
Role string
JTI string
TenantID *int64
}
// Manager handles login, token issuance, validation, and logout.
type Manager struct {
store *userstore.Store
jwtSecret []byte
// ldap is nil until SetLDAP wires the directory integration. When nil the
// login flow is local-only (bcrypt).
ldap *ldapComponent
}
// ldapComponent bundles the dependencies for LDAP authentication. Kept behind
// a pointer so a deployment without LDAP configured pays zero cost.
type ldapComponent struct {
store *ldapstore.Store
authn *ldapauth.Authenticator
tenants *tenantstore.Store
audlog *audit.Logger
limiter *keyedRateLimiter
}
// New creates a new auth Manager.
func New(store *userstore.Store, jwtSecret string) *Manager {
return &Manager{store: store, jwtSecret: []byte(jwtSecret)}
}
// SetLDAP wires the LDAP directory integration into the auth manager. Called
// once at startup after the ldapstore/tenantstore/audit dependencies exist.
func (m *Manager) SetLDAP(ldapSt *ldapstore.Store, authn *ldapauth.Authenticator, tenants *tenantstore.Store, audlog *audit.Logger) {
m.ldap = &ldapComponent{
store: ldapSt,
authn: authn,
tenants: tenants,
audlog: audlog,
// 10 attempts burst, refilled at 1 / 6s per key (~10/min sustained).
limiter: newKeyedRateLimiter(10, 1.0/6.0),
}
}
// Login verifies credentials and returns a signed JWT token. Kept for callers
// that do not have request context (client IP); prefer LoginFrom.
func (m *Manager) Login(username, password string) (token string, user *userstore.User, err error) {
return m.LoginFrom(context.Background(), username, password, "")
}
// LoginFrom verifies credentials, honouring each account's auth_source and,
// where applicable, the tenant's LDAP configuration. ip is used for
// rate-limiting and audit context.
//
// Decision matrix:
// - account exists, auth_source=local : bcrypt only, LDAP never attempted.
// - account exists, auth_source=ldap : LDAP bind only, no local-password fallback.
// - account absent, tenant LDAP enabled: LDAP bind + JIT provisioning.
// - otherwise : fail (with bcrypt timing burn).
func (m *Manager) LoginFrom(ctx context.Context, identifier, password, ip string) (string, *userstore.User, error) {
rec, err := m.store.FindForLogin(ctx, identifier)
switch {
case err == nil && rec.AuthSource == userstore.AuthSourceLDAP:
if m.ldap == nil {
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
return m.ldapLogin(ctx, derefTenant(rec.User.TenantID), identifier, password, ip, rec.User)
case err == nil:
if !rec.User.Active {
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
if cErr := userstore.CompareLocalPassword(rec.Hash, password); cErr != nil {
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
return m.issueToken(rec.User)
case errors.Is(err, userstore.ErrUserNotFound):
// Possible just-in-time LDAP provisioning.
if tid := m.jitTenant(ctx, identifier); tid != nil {
return m.ldapLogin(ctx, *tid, identifier, password, ip, nil)
}
m.store.BurnPasswordTiming(password)
return "", nil, fmt.Errorf("auth: login: invalid credentials")
default:
return "", nil, fmt.Errorf("auth: login: %w", err)
}
}
// jitTenant returns the tenant ID an unknown identifier may be provisioned
// into: the identifier's email domain must map to a tenant that has an enabled
// LDAP config. Returns nil when JIT is not applicable.
func (m *Manager) jitTenant(ctx context.Context, identifier string) *int64 {
if m.ldap == nil {
return nil
}
at := strings.LastIndex(identifier, "@")
if at < 0 || at == len(identifier)-1 {
return nil
}
domain := strings.ToLower(identifier[at+1:])
tid, err := m.ldap.tenants.GetTenantIDByDomain(ctx, domain)
if err != nil || tid == nil {
return nil
}
cfg, err := m.ldap.store.Get(ctx, *tid)
if err != nil || !cfg.Enabled {
return nil
}
return tid
}
// ldapLogin performs the LDAP bind, applies role mapping, JIT-provisions or
// re-synchronises the local user, and issues a token. existing may be nil (JIT).
func (m *Manager) ldapLogin(ctx context.Context, tenantID int64, loginName, password, ip string, existing *userstore.User) (string, *userstore.User, error) {
tid := tenantID
logFail := func(detail string) {
m.ldap.audlog.Log(audit.Entry{
EventType: audit.EventLdapLoginFailed, Username: loginName, IPAddress: ip,
TenantID: &tid, Success: false, Detail: detail,
})
}
// Rate limit per (tenant, loginName) and per source IP.
userKey := fmt.Sprintf("u:%d:%s", tenantID, strings.ToLower(loginName))
if !m.ldap.limiter.allow(userKey) || (ip != "" && !m.ldap.limiter.allow("ip:"+ip)) {
logFail("rate_limited")
return "", nil, fmt.Errorf("auth: login: too many attempts")
}
cfg, bindPw, err := m.ldap.store.GetWithSecret(ctx, tenantID)
if err != nil {
logFail("config_unavailable")
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
if !cfg.Enabled {
logFail("ldap_disabled")
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
res, err := m.ldap.authn.Authenticate(ctx, cfg, bindPw, loginName, password)
if err != nil {
logFail("bind_failed")
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
// Role mapping: admin group membership -> domain_admin, else user.
// LDAP can NEVER confer superadmin.
role := userstore.RoleUser
if res.IsAdmin {
role = userstore.RoleDomainAdmin
}
var user *userstore.User
if existing == nil {
username := res.Username
if username == "" {
username = loginName
}
email := res.Email
if email == "" {
email = loginName
}
user, err = m.store.CreateLDAPUser(ctx, userstore.LDAPUserRequest{
Username: username, Email: email, Role: role, LdapUID: res.Username, TenantID: &tid,
})
if err != nil {
logFail("provision_failed")
return "", nil, fmt.Errorf("auth: login: provisioning failed")
}
} else {
if existing.Role != role && existing.Role != userstore.RoleSuperAdmin {
m.ldap.audlog.Log(audit.Entry{
EventType: audit.EventLdapRoleSync, Username: existing.Username, IPAddress: ip,
TenantID: &tid, Success: true,
Detail: fmt.Sprintf("role %s -> %s", existing.Role, role),
})
}
// Never downgrade a superadmin via LDAP (defensive; LDAP accounts are
// never superadmin, but guard against manual DB edits).
syncRole := role
if existing.Role == userstore.RoleSuperAdmin {
syncRole = userstore.RoleSuperAdmin
}
email := res.Email
if email == "" {
email = existing.Email
}
if err := m.store.SyncLDAPUser(ctx, existing.ID, email, syncRole); err != nil {
logFail("sync_failed")
return "", nil, fmt.Errorf("auth: login: sync failed")
}
user, err = m.store.GetByID(existing.ID)
if err != nil {
logFail("reload_failed")
return "", nil, fmt.Errorf("auth: login: reload failed")
}
}
m.ldap.audlog.Log(audit.Entry{
EventType: audit.EventLdapLoginSuccess, Username: user.Username, IPAddress: ip,
TenantID: &tid, Success: true, Detail: "role:" + user.Role,
})
return m.issueToken(user)
}
func derefTenant(t *int64) int64 {
if t == nil {
return 0
}
return *t
}
func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) {
jti := generateJTI()
now := time.Now()
claims := jwt.MapClaims{
"sub": user.Username,
"email": user.Email,
"role": user.Role,
"uid": user.ID,
"jti": jti,
"iat": now.Unix(),
"exp": now.Add(8 * time.Hour).Unix(),
}
if user.TenantID != nil {
claims["tenant_id"] = *user.TenantID
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.jwtSecret)
if err != nil {
return "", nil, fmt.Errorf("auth: sign token: %w", err)
}
return signed, user, nil
}
// ValidateToken parses and validates the token, checking the blacklist.
func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
}
return m.jwtSecret, nil
})
if err != nil {
return nil, fmt.Errorf("auth: invalid token: %w", err)
}
if !token.Valid {
return nil, errors.New("auth: token not valid")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("auth: bad claims")
}
jti, _ := claims["jti"].(string)
blacklisted, err := m.store.IsBlacklisted(jti)
if err != nil {
return nil, fmt.Errorf("auth: blacklist check: %w", err)
}
if blacklisted {
return nil, errors.New("auth: token revoked")
}
username, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
role, _ := claims["role"].(string)
var userID int64
switch v := claims["uid"].(type) {
case float64:
userID = int64(v)
case int64:
userID = v
}
var tenantID *int64
switch v := claims["tenant_id"].(type) {
case float64:
id := int64(v)
tenantID = &id
case int64:
id := v
tenantID = &id
}
return &Session{
UserID: userID,
Username: username,
Email: email,
Role: role,
JTI: jti,
TenantID: tenantID,
}, nil
}
// Logout revokes the token by adding its JTI to the blacklist.
func (m *Manager) Logout(tokenStr string) error {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method")
}
return m.jwtSecret, nil
})
if err != nil {
return fmt.Errorf("auth: logout parse: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return errors.New("auth: bad claims on logout")
}
jti, _ := claims["jti"].(string)
var exp time.Time
switch v := claims["exp"].(type) {
case float64:
exp = time.Unix(int64(v), 0)
case int64:
exp = time.Unix(v, 0)
default:
exp = time.Now().Add(8 * time.Hour)
}
return m.store.BlacklistToken(jti, exp)
}
// HasRole returns true when userRole satisfies the required role level.
// Hierarchy: superadmin > domain_admin > user
func HasRole(userRole, required string) bool {
levels := map[string]int{
userstore.RoleUser: 1,
userstore.RoleDomainAdmin: 2,
userstore.RoleSuperAdmin: 3,
}
return levels[userRole] >= levels[required]
}
// generateJTI returns a cryptographically random identifier for a JWT.
func generateJTI() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// GetUserStore returns the underlying user store.
func (m *Manager) GetUserStore() *userstore.Store {
return m.store
}