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() identity, err := ldapAuth.AuthenticateAndFetchGroups(ctx, username, password) 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, "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(), "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, "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_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, "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_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 }