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:
+109
-5
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user