479c27e5a8
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>
293 lines
7.3 KiB
Go
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)
|
|
}
|