317 lines
11 KiB
Go
317 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"vctp/internal/auth"
|
|
"vctp/server/audit"
|
|
"vctp/server/middleware"
|
|
"vctp/server/models"
|
|
)
|
|
|
|
const (
|
|
authLoginFailureMessage = "invalid username or password"
|
|
authLoginRequestTimeout = 30 * time.Second
|
|
maxDebugLogListItems = 25
|
|
)
|
|
|
|
type ldapAuthenticator interface {
|
|
AuthenticateAndFetchGroups(ctx context.Context, username string, password string) (auth.LDAPIdentity, error)
|
|
}
|
|
|
|
type jwtService interface {
|
|
IssueToken(subject string, roles []string, groups []string) (string, auth.Claims, error)
|
|
}
|
|
|
|
var newLDAPAuthenticator = func(cfg auth.LDAPConfig) (ldapAuthenticator, error) {
|
|
return auth.NewLDAPAuthenticator(cfg)
|
|
}
|
|
|
|
var newJWTService = func(cfg auth.JWTConfig) (jwtService, error) {
|
|
return auth.NewJWTService(cfg)
|
|
}
|
|
|
|
// AuthLogin authenticates a user against LDAP and returns a signed JWT.
|
|
// @Summary Login
|
|
// @Description Authenticates a username/password against LDAP and returns a signed access token.
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param payload body models.AuthLoginRequest true "Login credentials"
|
|
// @Success 200 {object} models.AuthLoginResponse "Login success"
|
|
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
|
// @Failure 401 {object} models.ErrorResponse "Invalid credentials"
|
|
// @Failure 500 {object} models.ErrorResponse "Server error"
|
|
// @Failure 503 {object} models.ErrorResponse "Authentication disabled"
|
|
// @Router /api/auth/login [post]
|
|
func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if h == nil || h.Settings == nil || h.Settings.Values == nil {
|
|
audit.LogAuthEvent(nil, r, "login", "error", "reason", "settings_not_configured")
|
|
writeJSONError(w, http.StatusInternalServerError, "authentication is not configured")
|
|
return
|
|
}
|
|
|
|
cfg := h.Settings.Values.Settings
|
|
if !cfg.AuthEnabled {
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "auth_disabled")
|
|
writeJSONError(w, http.StatusServiceUnavailable, "authentication is disabled")
|
|
return
|
|
}
|
|
|
|
var req models.AuthLoginRequest
|
|
if err := decodeJSONBody(w, r, &req); err != nil {
|
|
h.Logger.Error("unable to decode auth login request", "error", err)
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "invalid_request_json", "error", err)
|
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
username := strings.TrimSpace(req.Username)
|
|
password := req.Password
|
|
if username == "" || strings.TrimSpace(password) == "" {
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "missing_username_or_password", "username", username)
|
|
writeJSONError(w, http.StatusBadRequest, "username and password are required")
|
|
return
|
|
}
|
|
audit.LogAuthEvent(h.Logger, r, "login", "observe",
|
|
"reason", "ldap_authentication_start",
|
|
"username", username,
|
|
"ldap_bind_address", cfg.LDAPBindAddress,
|
|
"ldap_base_dn", cfg.LDAPBaseDN,
|
|
"ldap_group_requirements", limitStrings(cfg.LDAPGroups, maxDebugLogListItems),
|
|
"auth_group_role_mapping_keys", limitStrings(sortedStringMapKeys(cfg.AuthGroupRoleMappings), maxDebugLogListItems),
|
|
"ldap_insecure", cfg.LDAPInsecure,
|
|
"ldap_disable_validation", cfg.LDAPDisableValidation,
|
|
"ldap_trust_cert_configured", strings.TrimSpace(cfg.LDAPTrustCertFile) != "",
|
|
)
|
|
|
|
ldapAuth, err := newLDAPAuthenticator(auth.LDAPConfig{
|
|
BindAddress: cfg.LDAPBindAddress,
|
|
BaseDN: cfg.LDAPBaseDN,
|
|
TrustCertFile: cfg.LDAPTrustCertFile,
|
|
DisableValidation: cfg.LDAPDisableValidation,
|
|
Insecure: cfg.LDAPInsecure,
|
|
DialTimeout: authLoginRequestTimeout,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to initialize ldap authenticator", "error", err)
|
|
audit.LogAuthEvent(h.Logger, r, "login", "error", "reason", "ldap_authenticator_init_failed", "username", username, "error", err)
|
|
writeJSONError(w, http.StatusInternalServerError, "authentication service unavailable")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := withRequestTimeout(r, authLoginRequestTimeout)
|
|
defer cancel()
|
|
ldapAuthStartedAt := time.Now()
|
|
identity, err := ldapAuth.AuthenticateAndFetchGroups(ctx, username, password)
|
|
ldapAuthDuration := time.Since(ldapAuthStartedAt)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrLDAPInvalidCredentials) {
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
|
"reason", "invalid_credentials",
|
|
"username", username,
|
|
"ldap_bind_address", cfg.LDAPBindAddress,
|
|
"ldap_base_dn", cfg.LDAPBaseDN,
|
|
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
|
"error", err,
|
|
)
|
|
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
|
return
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
|
"reason", "ldap_timeout",
|
|
"username", username,
|
|
"ldap_bind_address", cfg.LDAPBindAddress,
|
|
"ldap_base_dn", cfg.LDAPBaseDN,
|
|
"timeout_seconds", authLoginRequestTimeout.Seconds(),
|
|
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
|
"error", err,
|
|
)
|
|
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
|
return
|
|
}
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
|
"reason", "ldap_authentication_failed",
|
|
"username", username,
|
|
"ldap_bind_address", cfg.LDAPBindAddress,
|
|
"ldap_base_dn", cfg.LDAPBaseDN,
|
|
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
|
"error", err,
|
|
)
|
|
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
|
return
|
|
}
|
|
audit.LogAuthEvent(h.Logger, r, "login", "observe",
|
|
"reason", "ldap_authentication_succeeded",
|
|
"username", username,
|
|
"ldap_identity_username", identity.Username,
|
|
"ldap_user_dn", identity.UserDN,
|
|
"ldap_group_count", len(identity.Groups),
|
|
"ldap_groups", limitStrings(identity.Groups, maxDebugLogListItems),
|
|
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
|
"ldap_bind_duration_ms", identity.BindDuration.Milliseconds(),
|
|
"ldap_user_lookup_duration_ms", identity.UserLookupDuration.Milliseconds(),
|
|
"ldap_group_lookup_duration_ms", identity.GroupMembershipLookupDuration.Milliseconds(),
|
|
"ldap_diagnostics", limitStrings(identity.Diagnostics, maxDebugLogListItems),
|
|
)
|
|
|
|
roles := auth.ResolveRoles(identity.Groups, cfg.AuthGroupRoleMappings)
|
|
hasRequiredGroup := auth.HasAnyGroup(identity.Groups, cfg.LDAPGroups)
|
|
audit.LogAuthEvent(h.Logger, r, "login", "observe",
|
|
"reason", "authorization_evaluation",
|
|
"username", username,
|
|
"has_required_group", hasRequiredGroup,
|
|
"required_groups", limitStrings(cfg.LDAPGroups, maxDebugLogListItems),
|
|
"user_groups", limitStrings(identity.Groups, maxDebugLogListItems),
|
|
"resolved_roles", roles,
|
|
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
|
"ldap_bind_duration_ms", identity.BindDuration.Milliseconds(),
|
|
"ldap_user_lookup_duration_ms", identity.UserLookupDuration.Milliseconds(),
|
|
"ldap_group_lookup_duration_ms", identity.GroupMembershipLookupDuration.Milliseconds(),
|
|
"auth_group_role_mapping_keys", limitStrings(sortedStringMapKeys(cfg.AuthGroupRoleMappings), maxDebugLogListItems),
|
|
)
|
|
if !hasRequiredGroup || len(roles) == 0 {
|
|
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
|
"reason", "group_or_role_denied",
|
|
"username", username,
|
|
"group_count", len(identity.Groups),
|
|
"has_required_group", hasRequiredGroup,
|
|
"required_groups", limitStrings(cfg.LDAPGroups, maxDebugLogListItems),
|
|
"user_groups", limitStrings(identity.Groups, maxDebugLogListItems),
|
|
"resolved_roles", roles,
|
|
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
|
"ldap_bind_duration_ms", identity.BindDuration.Milliseconds(),
|
|
"ldap_user_lookup_duration_ms", identity.UserLookupDuration.Milliseconds(),
|
|
"ldap_group_lookup_duration_ms", identity.GroupMembershipLookupDuration.Milliseconds(),
|
|
"ldap_diagnostics", limitStrings(identity.Diagnostics, maxDebugLogListItems),
|
|
)
|
|
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
|
return
|
|
}
|
|
|
|
jwtSvc, err := newJWTService(auth.JWTConfig{
|
|
SigningKeyBase64: cfg.AuthJWTSigningKey,
|
|
Issuer: cfg.AuthJWTIssuer,
|
|
Audience: cfg.AuthJWTAudience,
|
|
TokenLifespan: time.Duration(cfg.AuthTokenLifespanMinutes) * time.Minute,
|
|
ClockSkew: time.Duration(cfg.AuthClockSkewSeconds) * time.Second,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to initialize jwt service", "error", err)
|
|
audit.LogAuthEvent(h.Logger, r, "login", "error", "reason", "jwt_service_init_failed", "username", username, "error", err)
|
|
writeJSONError(w, http.StatusInternalServerError, "authentication service unavailable")
|
|
return
|
|
}
|
|
|
|
subject := strings.TrimSpace(identity.Username)
|
|
if subject == "" {
|
|
subject = username
|
|
}
|
|
token, claims, err := jwtSvc.IssueToken(subject, roles, identity.Groups)
|
|
if err != nil {
|
|
h.Logger.Error("failed to issue auth token", "username", username, "error", err)
|
|
audit.LogAuthEvent(h.Logger, r, "login", "error", "reason", "token_issue_failed", "username", username, "error", err)
|
|
writeJSONError(w, http.StatusInternalServerError, "failed to issue access token")
|
|
return
|
|
}
|
|
|
|
audit.LogAuthEvent(h.Logger, r, "login", "allow", "username", subject, "resolved_roles", roles, "expires_at", claims.ExpiresAt)
|
|
writeJSON(w, http.StatusOK, models.AuthLoginResponse{
|
|
AccessToken: token,
|
|
ExpiresAt: claims.ExpiresAt,
|
|
TokenType: "Bearer",
|
|
})
|
|
}
|
|
|
|
// AuthMe returns the currently authenticated identity from validated JWT claims.
|
|
// @Summary Who am I
|
|
// @Description Returns JWT claims for the currently authenticated bearer token.
|
|
// @Description Requires Bearer authentication.
|
|
// @Tags auth
|
|
// @Produce json
|
|
// @Success 200 {object} models.AuthMeResponse "Authenticated identity"
|
|
// @Failure 401 {object} models.ErrorResponse "Missing or invalid authentication context"
|
|
// @Router /api/auth/me [get]
|
|
// @Security BearerAuth
|
|
func (h *Handler) AuthMe(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
claims, ok := middleware.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
audit.LogAuthEvent(h.Logger, r, "whoami", "deny", "reason", "missing_auth_context")
|
|
writeJSONError(w, http.StatusUnauthorized, "missing authentication context")
|
|
return
|
|
}
|
|
|
|
audit.LogAuthEvent(h.Logger, r, "whoami", "allow", "subject", claims.Subject, "roles", claims.Roles)
|
|
writeJSON(w, http.StatusOK, models.AuthMeResponse{
|
|
Status: "OK",
|
|
Subject: claims.Subject,
|
|
Roles: claims.Roles,
|
|
Groups: claims.Groups,
|
|
Issuer: claims.Issuer,
|
|
Audience: claims.Audience,
|
|
IssuedAt: claims.IssuedAt,
|
|
ExpiresAt: claims.ExpiresAt,
|
|
NotBefore: claims.NotBefore,
|
|
TokenID: claims.ID,
|
|
})
|
|
}
|
|
|
|
func sortedStringMapKeys(values map[string]string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func limitStrings(values []string, maxItems int) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
if maxItems <= 0 || len(values) <= maxItems {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
out := make([]string, 0, maxItems+1)
|
|
for _, value := range values[:maxItems] {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
out = append(out, "...")
|
|
return out
|
|
}
|