9298216ce0
- PROJ-40: /api/health mit Version+Uptime, /metrics Prometheus-Format (mails_last_60min/24h/7d/30d, mails_total, storage_bytes, tenants_total, users_total, uptime_seconds) — Token-Schutz optional konfigurierbar - PROJ-41: GET /api/admin/stats/timeseries (30-Tage tagesgenau, Tenant-scoped) + SVG-Balkendiagramm im Dashboard (Mail-Eingang letzte 30 Tage) - storage.DBQueryRow() Helper für Metrics-Queries ohne Pool-Exposition - config.MetricsConfig (enabled, token) in config.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
486 lines
18 KiB
Go
486 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"regexp"
|
|
|
|
"archivmail/config"
|
|
"archivmail/internal/audit"
|
|
"archivmail/internal/auth"
|
|
imapstore "archivmail/internal/imap"
|
|
"archivmail/internal/index"
|
|
ldapcfg "archivmail/internal/ldapconfig"
|
|
"archivmail/internal/mailer"
|
|
pop3store "archivmail/internal/pop3"
|
|
"archivmail/internal/smtpoutconfig"
|
|
"archivmail/internal/smtpd"
|
|
"archivmail/internal/storage"
|
|
"archivmail/internal/tenantstore"
|
|
"archivmail/internal/tokenstore"
|
|
"archivmail/internal/userstore"
|
|
)
|
|
|
|
// SEC-22: Compiled regex for mail ID validation to prevent path traversal.
|
|
var mailIDRegex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// isValidMailID validates that a mail ID matches the expected hex format.
|
|
func isValidMailID(id string) bool {
|
|
return mailIDRegex.MatchString(id)
|
|
}
|
|
|
|
// roleLevel returns the privilege level for a role string.
|
|
// Hierarchy: superadmin=5 > domain_admin=4 > domain_auditor=3 > auditor=2 > user=1
|
|
// Separation of duties: admins (superadmin, domain_admin) have NO mail access.
|
|
// Mail access: domain_auditor (all tenant mails), auditor (own mails), user (own mails).
|
|
func roleLevel(role string) int {
|
|
levels := map[string]int{
|
|
userstore.RoleUser: 1,
|
|
userstore.RoleAuditor: 2,
|
|
userstore.RoleDomainAuditor: 3,
|
|
userstore.RoleDomainAdmin: 4,
|
|
userstore.RoleAdmin: 4, // legacy alias for domain_admin
|
|
userstore.RoleSuperAdmin: 5,
|
|
}
|
|
return levels[role]
|
|
}
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
sessionKey contextKey = "session"
|
|
tenantKey contextKey = "tenant_id"
|
|
)
|
|
|
|
// Server is the archivmail HTTP API server.
|
|
type Server struct {
|
|
cfg config.APIConfig
|
|
metricsCfg config.MetricsConfig
|
|
startTime time.Time
|
|
store *storage.Store
|
|
idx index.Indexer
|
|
authMgr *auth.Manager
|
|
users *userstore.Store
|
|
audlog *audit.Logger
|
|
logger *slog.Logger
|
|
mux *http.ServeMux
|
|
smtpDaemon *smtpd.Daemon
|
|
imapStore *imapstore.Store
|
|
imapImporter *imapstore.Importer
|
|
imapScheduler *imapstore.Scheduler
|
|
pop3Store *pop3store.Store
|
|
pop3Importer *pop3store.Importer
|
|
uploadJobs sync.Map // jobID → *UploadJob
|
|
ldapStore *ldapcfg.Store
|
|
tenantStore *tenantstore.Store
|
|
tenantLdapStore *ldapcfg.TenantStore
|
|
idxMgr index.TenantIndexer
|
|
appVersion string
|
|
moduleVersions map[string]string
|
|
globalRetentionDays int // from storage config (PROJ-34)
|
|
mailer *mailer.Mailer
|
|
tokenStore *tokenstore.Store
|
|
fqdn string // from server.fqdn config (PROJ-28)
|
|
smtpOutStore *smtpoutconfig.Store
|
|
}
|
|
|
|
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
|
func (s *Server) SetSMTPDaemon(d *smtpd.Daemon) {
|
|
s.smtpDaemon = d
|
|
}
|
|
|
|
// SetImap wires the IMAP store, importer, and scheduler into the API server after construction.
|
|
func (s *Server) SetImap(store *imapstore.Store, importer *imapstore.Importer, scheduler *imapstore.Scheduler) {
|
|
s.imapStore = store
|
|
s.imapImporter = importer
|
|
s.imapScheduler = scheduler
|
|
}
|
|
|
|
// SetPop3 wires the POP3 store and importer into the API server after construction.
|
|
func (s *Server) SetPop3(store *pop3store.Store, importer *pop3store.Importer) {
|
|
s.pop3Store = store
|
|
s.pop3Importer = importer
|
|
}
|
|
|
|
// SetIndexManager wires the per-tenant index manager into the API server (PROJ-21 Phase 4).
|
|
func (s *Server) SetIndexManager(mgr index.TenantIndexer) {
|
|
s.idxMgr = mgr
|
|
}
|
|
|
|
// SetVersion wires app version and module versions into the API server.
|
|
func (s *Server) SetVersion(appVersion string, modules map[string]string) {
|
|
s.appVersion = appVersion
|
|
s.moduleVersions = modules
|
|
}
|
|
|
|
// SetGlobalRetentionDays wires the global retention_days from storage config into the API server.
|
|
func (s *Server) SetGlobalRetentionDays(days int) {
|
|
s.globalRetentionDays = days
|
|
}
|
|
|
|
// SetMetrics wires the metrics config into the API server.
|
|
func (s *Server) SetMetrics(cfg config.MetricsConfig) {
|
|
s.metricsCfg = cfg
|
|
}
|
|
|
|
// SetMailer wires the SMTP-Out mailer into the API server (PROJ-28).
|
|
func (s *Server) SetMailer(m *mailer.Mailer) {
|
|
s.mailer = m
|
|
}
|
|
|
|
// SetTokenStore wires the token store into the API server (PROJ-28).
|
|
func (s *Server) SetTokenStore(ts *tokenstore.Store) {
|
|
s.tokenStore = ts
|
|
}
|
|
|
|
// SetFQDN wires the server FQDN for link generation (PROJ-28).
|
|
func (s *Server) SetFQDN(fqdn string) {
|
|
s.fqdn = fqdn
|
|
}
|
|
|
|
// SetSMTPOutStore wires the SMTP-Out config store into the API server.
|
|
func (s *Server) SetSMTPOutStore(store *smtpoutconfig.Store) {
|
|
s.smtpOutStore = store
|
|
}
|
|
|
|
// New creates and wires up a new API server.
|
|
func New(
|
|
cfg config.APIConfig,
|
|
store *storage.Store,
|
|
idx index.Indexer,
|
|
authMgr *auth.Manager,
|
|
users *userstore.Store,
|
|
audlog *audit.Logger,
|
|
logger *slog.Logger,
|
|
) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
store: store,
|
|
idx: idx,
|
|
authMgr: authMgr,
|
|
users: users,
|
|
audlog: audlog,
|
|
logger: logger,
|
|
mux: http.NewServeMux(),
|
|
startTime: time.Now(),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// auth wraps a handler with authentication + tenant context propagation.
|
|
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
|
|
return s.authMiddleware(s.tenantMiddleware(h))
|
|
}
|
|
|
|
// authAdmin wraps a handler requiring at least admin role.
|
|
func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
|
|
return s.authMiddleware(s.tenantMiddleware(s.requireRole(userstore.RoleDomainAdmin, h)))
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("GET /api/health", s.handleHealth)
|
|
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
|
|
s.mux.HandleFunc("GET /api/version", s.handleVersion)
|
|
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
|
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
|
|
s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout))
|
|
|
|
// PROJ-28: Self-Service Onboarding
|
|
s.mux.HandleFunc("POST /api/auth/signup", s.handleSignup)
|
|
s.mux.HandleFunc("GET /api/auth/verify", s.handleVerifyEmail)
|
|
s.mux.HandleFunc("POST /api/auth/forgot-password", s.handleForgotPassword)
|
|
s.mux.HandleFunc("POST /api/auth/reset-password", s.handleResetPassword)
|
|
s.mux.HandleFunc("GET /api/auth/invite", s.handleCheckInvite)
|
|
s.mux.HandleFunc("POST /api/admin/invite", s.authAdmin(s.handleCreateInvite))
|
|
s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers))
|
|
s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser))
|
|
s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser))
|
|
s.mux.HandleFunc("DELETE /api/users/{id}", s.authAdmin(s.handleDeleteUser))
|
|
s.mux.HandleFunc("GET /api/search", s.auth(s.handleSearch))
|
|
s.mux.HandleFunc("GET /api/audit", s.auth(s.requireRole(userstore.RoleAuditor, s.handleAuditLog)))
|
|
s.mux.HandleFunc("GET /api/admin/smtp/status", s.authAdmin(s.handleSMTPStatus))
|
|
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats))
|
|
s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail)))
|
|
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment)))
|
|
s.mux.HandleFunc("GET /api/threads/{threadID}", s.auth(s.handleGetThread))
|
|
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw)))
|
|
s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices))
|
|
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction))
|
|
|
|
s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats))
|
|
s.mux.HandleFunc("GET /api/admin/stats/timeseries", s.authAdmin(s.handleMailTimeseries))
|
|
s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit))
|
|
// SEC-17: Security fix actions require superadmin, not just domain_admin.
|
|
s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix)))
|
|
|
|
// PROJ-34: Retention — superadmin only
|
|
s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge)))
|
|
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
|
|
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
|
|
|
|
// SMTP-Out Relay Konfiguration — superadmin only
|
|
s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut)))
|
|
s.mux.HandleFunc("PUT /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSaveSMTPOut)))
|
|
s.mux.HandleFunc("DELETE /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteSMTPOut)))
|
|
s.mux.HandleFunc("POST /api/admin/smtp-out/test", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleTestSMTPOut)))
|
|
|
|
// PROJ-29: Quotas — superadmin only
|
|
s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage)))
|
|
s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
|
|
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantQuota)))
|
|
// Spec-konforme Alias-Route (PROJ-29 Acceptance Criterion 4)
|
|
s.mux.HandleFunc("GET /api/admin/tenants/{id}/usage", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
|
|
|
|
// PROJ-33: IMAP mode settings — domain_admin only
|
|
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
|
s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode))
|
|
|
|
// Export routes
|
|
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
|
|
s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
|
|
s.mux.HandleFunc("POST /api/export/ediscovery", s.auth(s.handleExportEDiscovery))
|
|
|
|
// Upload routes (admin only)
|
|
s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload))
|
|
s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authAdmin(s.handleUploadProgress))
|
|
|
|
// Upload routes (all authenticated users)
|
|
s.mux.HandleFunc("POST /api/upload", s.auth(s.handleUpload))
|
|
s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.auth(s.handleUploadProgress))
|
|
|
|
// IMAP routes (accessible to all authenticated users)
|
|
s.mux.HandleFunc("GET /api/imap", s.auth(s.handleListImap))
|
|
s.mux.HandleFunc("POST /api/imap", s.auth(s.handleCreateImap))
|
|
s.mux.HandleFunc("DELETE /api/imap/{id}", s.auth(s.handleDeleteImap))
|
|
s.mux.HandleFunc("PATCH /api/imap/{id}", s.auth(s.handleUpdateImapInterval))
|
|
s.mux.HandleFunc("POST /api/imap/test", s.auth(s.handleTestImap))
|
|
s.mux.HandleFunc("POST /api/imap/{id}/import", s.auth(s.handleStartImport))
|
|
s.mux.HandleFunc("GET /api/imap/{id}/progress", s.auth(s.handleImapProgress))
|
|
s.mux.HandleFunc("POST /api/imap/{id}/sync", s.auth(s.handleSyncNow))
|
|
|
|
// POP3 routes (accessible to all authenticated users)
|
|
s.mux.HandleFunc("GET /api/pop3", s.auth(s.handleListPop3))
|
|
s.mux.HandleFunc("POST /api/pop3", s.auth(s.handleCreatePop3))
|
|
s.mux.HandleFunc("DELETE /api/pop3/{id}", s.auth(s.handleDeletePop3))
|
|
s.mux.HandleFunc("POST /api/pop3/test", s.auth(s.handleTestPop3))
|
|
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
|
|
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
|
|
|
|
// PROJ-25: Profile routes (password & email change)
|
|
s.mux.HandleFunc("PATCH /api/auth/password", s.auth(s.handleChangePassword))
|
|
s.mux.HandleFunc("PATCH /api/auth/email", s.auth(s.handleChangeEmail))
|
|
|
|
// PROJ-24: TOTP 2FA routes
|
|
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
|
s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost))
|
|
s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable))
|
|
s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token
|
|
s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset))
|
|
|
|
// Certificate management routes (superadmin only)
|
|
s.mux.HandleFunc("GET /api/admin/cert/info", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertInfo)))
|
|
s.mux.HandleFunc("POST /api/admin/cert/upload", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertUpload)))
|
|
s.mux.HandleFunc("POST /api/admin/cert/self-signed", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertSelfSigned)))
|
|
s.mux.HandleFunc("POST /api/admin/cert/acme", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertACME)))
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
|
|
// --- middleware ---
|
|
|
|
const sessionCookieName = "archivmail_session"
|
|
|
|
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Prefer httpOnly cookie; fall back to Bearer token for CLI/API clients.
|
|
token := ""
|
|
if c, err := r.Cookie(sessionCookieName); err == nil {
|
|
token = c.Value
|
|
}
|
|
if token == "" {
|
|
token = extractBearerToken(r)
|
|
}
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing authorization")
|
|
return
|
|
}
|
|
|
|
sess, err := s.authMgr.ValidateToken(token)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess == nil || !auth.HasRole(sess.Role, role) {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// ── Mail access middleware ────────────────────────────────────────────────
|
|
|
|
// requireMailAccess checks that the caller may read mail content.
|
|
// SEC-29: Strict separation of duties — admins manage, auditors review.
|
|
// Mail access is granted ONLY to: user, auditor, domain_auditor.
|
|
// superadmin and domain_admin are explicitly denied (manage system/tenant, not content).
|
|
func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
// SEC-29: Admins must not access mail content.
|
|
switch sess.Role {
|
|
case userstore.RoleSuperAdmin, userstore.RoleDomainAdmin:
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
func extractBearerToken(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimPrefix(h, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sessionFromCtx(ctx context.Context) *auth.Session {
|
|
v := ctx.Value(sessionKey)
|
|
if v == nil {
|
|
return &auth.Session{}
|
|
}
|
|
if s, ok := v.(*auth.Session); ok {
|
|
return s
|
|
}
|
|
return &auth.Session{}
|
|
}
|
|
|
|
// tenantMiddleware extracts the tenant_id from the session and stores it in
|
|
// the request context, making it available to all downstream handlers.
|
|
func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
session := sessionFromCtx(r.Context())
|
|
if session != nil && session.TenantID != nil {
|
|
ctx := context.WithValue(r.Context(), tenantKey, session.TenantID)
|
|
next(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// tenantFromCtx extracts the tenant_id from context. Returns nil for global (superadmin) context.
|
|
func tenantFromCtx(ctx context.Context) *int64 {
|
|
v, _ := ctx.Value(tenantKey).(*int64)
|
|
return v
|
|
}
|
|
|
|
// sanitizeFilename strips characters that could be used for HTTP header injection
|
|
// (quotes, newlines, control chars) from attachment filenames coming from parsed
|
|
// e-mails. Only alphanumerics, spaces, dots, hyphens, and underscores are kept.
|
|
func sanitizeFilename(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range name {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
|
|
r == '.' || r == '-' || r == '_' || r == ' ' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// classifyLoginError maps internal login errors to safe audit-log categories.
|
|
// Raw error messages must not be stored in audit logs since auditor-role
|
|
// users can read them via GET /api/audit and internal details (LDAP hostnames,
|
|
// port numbers, etc.) would be exposed.
|
|
func classifyLoginError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
msg := err.Error()
|
|
switch {
|
|
case strings.Contains(msg, "not found"), strings.Contains(msg, "invalid password"),
|
|
strings.Contains(msg, "invalid credentials"):
|
|
return "invalid_password"
|
|
case strings.Contains(msg, "ldap"), strings.Contains(msg, "LDAP"):
|
|
return "ldap_error"
|
|
case strings.Contains(msg, "disabled"), strings.Contains(msg, "inactive"):
|
|
return "account_disabled"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// remoteIP returns the real client IP.
|
|
// X-Forwarded-For is only trusted when the direct connection comes from a
|
|
// configured trusted proxy. Otherwise r.RemoteAddr is used directly to prevent
|
|
// clients from spoofing their IP in audit logs and rate-limit counters.
|
|
func (s *Server) remoteIP(r *http.Request) string {
|
|
directIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
if directIP == "" {
|
|
directIP = r.RemoteAddr
|
|
}
|
|
if len(s.cfg.TrustedProxies) > 0 && isTrustedProxy(directIP, s.cfg.TrustedProxies) {
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
return strings.TrimSpace(strings.Split(fwd, ",")[0])
|
|
}
|
|
}
|
|
return directIP
|
|
}
|
|
|
|
// isTrustedProxy checks whether ip is in the list of trusted proxy addresses/CIDRs.
|
|
func isTrustedProxy(ip string, proxies []string) bool {
|
|
parsed := net.ParseIP(ip)
|
|
for _, p := range proxies {
|
|
if strings.Contains(p, "/") {
|
|
_, cidr, err := net.ParseCIDR(p)
|
|
if err == nil && cidr.Contains(parsed) {
|
|
return true
|
|
}
|
|
} else if p == ip {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|