Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
381 lines
11 KiB
Go
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
|
|
}
|