Files
archivmail/internal/auth/auth.go
T
sysops 479c27e5a8 feat(PROJ-21): Phase 2+3+5+8 Multi-Tenancy + PROJ-2 EML/MBOX Upload
Phase 2a: userstore domain_admin/superadmin Rollen, User.TenantID,
          ListByTenant, UpsertLDAPUser mit tenantID
Phase 2b: storage.Save() mit tenantID *int64, email_refs Tabelle,
          GetTenantForMail, GetAllIDsByTenant, StatsByTenant
Phase 2c: JWT-Claims tenant_id/tenant_slug, Session.TenantID,
          Login Domain-Erkennung via E-Mail-Domain
Phase 3:  tenantMiddleware, Handler-Filterung (Users, Mail, Stats)
Phase 5:  SMTP Domain-Routing via DomainToTenantFunc Callback,
          config smtp.tenant_routing + default_tenant_id
Phase 8:  archivmail migrate-tenants Subkommando
PROJ-2:   Upload-Seite /admin/upload mit DropZone + Progress-Polling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:03:40 +01:00

293 lines
7.3 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/archivmail/internal/ldapauth"
ldapcfg "github.com/archivmail/internal/ldapconfig"
"github.com/archivmail/internal/userstore"
)
// Session holds the claims extracted from a validated JWT.
type Session struct {
UserID int64
Username string
Role string
JTI string // unique JWT ID
TenantID *int64
TenantSlug string
}
// Manager handles login, token issuance, validation, and logout.
type Manager struct {
store *userstore.Store
ldapStore *ldapcfg.Store
jwtSecret []byte
}
// New creates a new auth Manager.
// ldapStore may be nil; in that case LDAP fallback is disabled.
func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Manager {
return &Manager{
store: store,
ldapStore: ldapStore,
jwtSecret: []byte(jwtSecret),
}
}
// Login verifies credentials and returns a signed JWT token.
// It first attempts a local password check. If that fails and LDAP is
// configured and enabled, it falls back to LDAP authentication.
func (m *Manager) Login(username, password string) (string, *userstore.User, error) {
// 1. Try local authentication first.
user, err := m.store.VerifyPassword(username, password)
if err == nil {
return m.issueToken(user)
}
// 2. LDAP fallback when the store is wired and the config is enabled.
if m.ldapStore != nil {
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
if ldapErr == nil && cfg != nil && cfg.Enabled {
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
URL: cfg.URL,
BindDN: cfg.BindDN,
BindPassword: cfg.BindPassword,
BaseDN: cfg.BaseDN,
UserFilter: cfg.UserFilter,
TLS: cfg.TLS,
TLSSkipVerify: cfg.TLSSkipVerify,
}, username, password)
if authErr == nil {
// Determine role: check group_mappings first, fall back to default_role.
role := cfg.DefaultRole
if role == "" {
role = userstore.RoleUser
}
memberOf := attrs["memberOf"]
if memberOf != "" {
for _, gm := range cfg.GroupMappings {
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
role = gm.Role
break
}
}
}
email := attrs["mail"]
if email == "" {
email = username + "@ldap.local"
}
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
if upsertErr == nil {
return m.issueToken(ldapUser)
}
}
}
}
return "", nil, fmt.Errorf("auth: login: invalid credentials")
}
// issueToken signs a JWT for the given user and returns the token string.
func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) {
jti := generateJTI()
now := time.Now()
var tenantIDVal int64
if user.TenantID != nil {
tenantIDVal = *user.TenantID
}
claims := jwt.MapClaims{
"sub": user.Username,
"role": user.Role,
"uid": user.ID,
"jti": jti,
"iat": now.Unix(),
"exp": now.Add(8 * time.Hour).Unix(),
"tenant_id": tenantIDVal,
}
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)
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:
if v != 0 {
id := int64(v)
tenantID = &id
}
case int64:
if v != 0 {
tenantID = &v
}
}
return &Session{
UserID: userID,
Username: username,
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 > admin > domain_admin > auditor > user
func HasRole(userRole, required string) bool {
levels := map[string]int{
userstore.RoleUser: 1,
userstore.RoleAuditor: 2,
userstore.RoleDomainAdmin: 3,
userstore.RoleAdmin: 4,
userstore.RoleSuperAdmin: 5,
}
return levels[userRole] >= levels[required]
}
// containsGroup checks whether a comma-separated memberOf string contains groupDN
// (case-insensitive substring match to handle varying DN formats).
func containsGroup(memberOf, groupDN string) bool {
for _, dn := range splitMemberOf(memberOf) {
if dn == groupDN {
return true
}
}
return false
}
func splitMemberOf(s string) []string {
var out []string
for _, part := range splitComma(s) {
part = trimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// splitComma splits on commas that are not part of DN attribute values.
// For simplicity we split on ", " and "," and let callers match.
func splitComma(s string) []string {
// Groups returned from LDAP memberOf are newline-separated in the map
// because we join with "," in ldapauth. Re-split here.
parts := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' && (i == 0 || s[i-1] != '\\') {
parts = append(parts, s[start:i])
start = i + 1
}
}
parts = append(parts, s[start:])
return parts
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
// generateJTI returns a cryptographically random identifier for a JWT.
func generateJTI() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// fallback: should never happen on a healthy system
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}