ac91dceac2
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>
258 lines
7.8 KiB
Go
258 lines
7.8 KiB
Go
// Package ldapconfig manages the LDAP/AD configuration stored in PostgreSQL.
|
|
// Exactly one configuration record may exist (id=1). The bind password is
|
|
// encrypted with AES-256-GCM using a SHA-256 derived key from the application
|
|
// secret — identical to the scheme used in internal/imap/store.go.
|
|
package ldapconfig
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// GroupMapping maps an LDAP group DN to an archivmail role.
|
|
type GroupMapping struct {
|
|
GroupDN string `json:"group_dn"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// LDAPConfig is the persisted LDAP/AD configuration.
|
|
type LDAPConfig struct {
|
|
ID int64 `json:"id"`
|
|
Enabled bool `json:"enabled"`
|
|
URL string `json:"url"`
|
|
BindDN string `json:"bind_dn"`
|
|
BindPassword string `json:"bind_password"` // masked as "••••••" in GET responses
|
|
BaseDN string `json:"base_dn"`
|
|
UserFilter string `json:"user_filter"`
|
|
TLS bool `json:"tls"`
|
|
TLSSkipVerify bool `json:"tls_skip_verify"`
|
|
DefaultRole string `json:"default_role"`
|
|
GroupMappings []GroupMapping `json:"group_mappings"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
UpdatedBy string `json:"updated_by"`
|
|
}
|
|
|
|
const createTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS ldap_config (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
|
url TEXT NOT NULL DEFAULT '',
|
|
bind_dn TEXT NOT NULL DEFAULT '',
|
|
bind_password BYTEA,
|
|
base_dn TEXT NOT NULL DEFAULT '',
|
|
user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)',
|
|
tls BOOLEAN NOT NULL DEFAULT false,
|
|
tls_skip_verify BOOLEAN NOT NULL DEFAULT false,
|
|
default_role VARCHAR(20) NOT NULL DEFAULT 'user',
|
|
group_mappings JSONB NOT NULL DEFAULT '[]',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_by TEXT NOT NULL DEFAULT ''
|
|
);
|
|
`
|
|
|
|
// Store manages LDAP configuration persistence.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
encKey [32]byte
|
|
}
|
|
|
|
// New connects to PostgreSQL, creates the table if needed, and returns a Store.
|
|
// secret is the application secret used to derive the AES-256 encryption key.
|
|
func New(dsn, secret string) (*Store, error) {
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ldapconfig: connect: %w", err)
|
|
}
|
|
|
|
key := sha256.Sum256([]byte(secret))
|
|
s := &Store{pool: pool, encKey: key}
|
|
|
|
if _, err := pool.Exec(ctx, createTableSQL); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("ldapconfig: init schema: %w", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Close releases the underlying connection pool.
|
|
func (s *Store) Close() {
|
|
s.pool.Close()
|
|
}
|
|
|
|
// Get returns the LDAP configuration with the bind password masked.
|
|
// Returns nil, nil when no configuration has been saved yet.
|
|
func (s *Store) Get(ctx context.Context) (*LDAPConfig, error) {
|
|
cfg, err := s.query(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg == nil {
|
|
return nil, nil
|
|
}
|
|
if cfg.BindPassword != "" {
|
|
cfg.BindPassword = "••••••"
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// GetWithPassword returns the LDAP configuration including the decrypted bind password.
|
|
// Returns nil, nil when no configuration has been saved yet.
|
|
func (s *Store) GetWithPassword(ctx context.Context) (*LDAPConfig, error) {
|
|
return s.query(ctx)
|
|
}
|
|
|
|
// Save upserts the LDAP configuration (always uses id=1).
|
|
// When bindPassword is empty the existing stored password is preserved.
|
|
func (s *Store) Save(ctx context.Context, cfg LDAPConfig, updatedBy string) error {
|
|
mappingsJSON, err := json.Marshal(cfg.GroupMappings)
|
|
if err != nil {
|
|
return fmt.Errorf("ldapconfig: marshal group_mappings: %w", err)
|
|
}
|
|
|
|
// Determine which password bytes to store.
|
|
var encryptedPw []byte
|
|
if cfg.BindPassword != "" {
|
|
encryptedPw, err = s.encrypt(cfg.BindPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("ldapconfig: encrypt password: %w", err)
|
|
}
|
|
} else {
|
|
// Preserve existing password: read it from DB.
|
|
existing, qErr := s.query(ctx)
|
|
if qErr == nil && existing != nil && existing.BindPassword != "" {
|
|
encryptedPw, err = s.encrypt(existing.BindPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("ldapconfig: re-encrypt existing password: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO ldap_config
|
|
(id, enabled, url, bind_dn, bind_password, base_dn, user_filter, tls, tls_skip_verify,
|
|
default_role, group_mappings, updated_at, updated_by)
|
|
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
url = EXCLUDED.url,
|
|
bind_dn = EXCLUDED.bind_dn,
|
|
bind_password = CASE WHEN EXCLUDED.bind_password IS NULL THEN ldap_config.bind_password ELSE EXCLUDED.bind_password END,
|
|
base_dn = EXCLUDED.base_dn,
|
|
user_filter = EXCLUDED.user_filter,
|
|
tls = EXCLUDED.tls,
|
|
tls_skip_verify = EXCLUDED.tls_skip_verify,
|
|
default_role = EXCLUDED.default_role,
|
|
group_mappings = EXCLUDED.group_mappings,
|
|
updated_at = NOW(),
|
|
updated_by = EXCLUDED.updated_by
|
|
`,
|
|
cfg.Enabled, cfg.URL, cfg.BindDN, encryptedPw, cfg.BaseDN,
|
|
cfg.UserFilter, cfg.TLS, cfg.TLSSkipVerify, cfg.DefaultRole,
|
|
string(mappingsJSON), updatedBy,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("ldapconfig: upsert: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes the LDAP configuration.
|
|
func (s *Store) Delete(ctx context.Context) error {
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM ldap_config WHERE id = 1`)
|
|
return err
|
|
}
|
|
|
|
// query reads the single LDAP config row and decrypts the bind password.
|
|
func (s *Store) query(ctx context.Context) (*LDAPConfig, error) {
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT id, enabled, url, bind_dn, bind_password, base_dn, user_filter,
|
|
tls, tls_skip_verify, default_role, group_mappings, updated_at, updated_by
|
|
FROM ldap_config WHERE id = 1
|
|
`)
|
|
|
|
var cfg LDAPConfig
|
|
var encPw []byte
|
|
var mappingsRaw []byte
|
|
|
|
err := row.Scan(
|
|
&cfg.ID, &cfg.Enabled, &cfg.URL, &cfg.BindDN, &encPw,
|
|
&cfg.BaseDN, &cfg.UserFilter, &cfg.TLS, &cfg.TLSSkipVerify,
|
|
&cfg.DefaultRole, &mappingsRaw, &cfg.UpdatedAt, &cfg.UpdatedBy,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ldapconfig: query: %w", err)
|
|
}
|
|
|
|
if len(encPw) > 0 {
|
|
plain, err := s.decrypt(encPw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ldapconfig: decrypt password: %w", err)
|
|
}
|
|
cfg.BindPassword = plain
|
|
}
|
|
|
|
if len(mappingsRaw) > 0 {
|
|
if err := json.Unmarshal(mappingsRaw, &cfg.GroupMappings); err != nil {
|
|
return nil, fmt.Errorf("ldapconfig: unmarshal group_mappings: %w", err)
|
|
}
|
|
}
|
|
if cfg.GroupMappings == nil {
|
|
cfg.GroupMappings = []GroupMapping{}
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// encrypt encrypts plaintext with AES-256-GCM using the store's key.
|
|
func (s *Store) encrypt(plaintext string) ([]byte, error) {
|
|
block, err := aes.NewCipher(s.encKey[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
|
|
}
|
|
|
|
// decrypt decrypts ciphertext produced by encrypt.
|
|
func (s *Store) decrypt(ciphertext []byte) (string, error) {
|
|
block, err := aes.NewCipher(s.encKey[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(ciphertext) < gcm.NonceSize() {
|
|
return "", fmt.Errorf("ldapconfig: ciphertext too short")
|
|
}
|
|
nonce, data := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
|
plain, err := gcm.Open(nil, nonce, data, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|