// 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 }