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.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user