feat(PROJ-22): LDAP Web-GUI + feat(PROJ-21): Multi-Tenancy Phase 1

PROJ-22 – LDAP Web-GUI Konfiguration & Test:
- internal/ldapconfig/store.go: AES-256-GCM Passwortspeicherung, CRUD Upsert (id=1)
- internal/ldapauth/client.go: TestConnection (RootDSE, UserCount) + Authenticate (2-step bind)
- internal/auth/auth.go: LDAP-Fallback in Login(), Gruppen-Rollenzuordnung, issueToken helper
- internal/api/ldap_tenants.go: GET/PUT/DELETE/POST-test /api/admin/ldap mit Audit-Log
- go.mod: github.com/go-ldap/ldap/v3 v3.4.8 hinzugefügt
- Frontend: LDAPConfig/LDAPTestResult Typen, LDAP-Tab mit Gruppen-Mappings + Testergebnis

PROJ-21 Phase 1+6+7 – Multi-Tenancy Grundstruktur:
- internal/tenantstore/store.go: tenants, tenant_domains, tenant_ldap Schema; Migration users/audit_log
- API: 8 Tenant-Routen (CRUD + Domain-Management) via SetTenants()
- cmd/archivmail/main.go: ldapSt + tenantSt initialisiert
- Frontend: Mandanten-Tab mit Tabelle, Domain-Dialog, Deaktivieren/Löschen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 20:27:56 +01:00
parent 4f0670d94c
commit ac91dceac2
13 changed files with 2063 additions and 11 deletions
+109 -5
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
@@ -8,6 +9,8 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/archivmail/internal/ldapauth"
ldapcfg "github.com/archivmail/internal/ldapconfig"
"github.com/archivmail/internal/userstore"
)
@@ -22,26 +25,77 @@ type Session struct {
// Manager handles login, token issuance, validation, and logout.
type Manager struct {
store *userstore.Store
ldap interface{} // placeholder for LDAP provider
ldapStore *ldapcfg.Store
jwtSecret []byte
}
// New creates a new auth Manager.
func New(store *userstore.Store, ldap interface{}, jwtSecret string) *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,
ldap: ldap,
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 "", nil, fmt.Errorf("auth: login: %w", err)
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)
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()
claims := jwt.MapClaims{
@@ -152,6 +206,56 @@ func HasRole(userRole, required string) bool {
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)