Files
vctp2/server/handler/auth.go
T
nathan 7848557002
continuous-integration/drone/push Build is passing
update docs
2026-04-17 14:00:48 +10:00

194 lines
7.3 KiB
Go

package handler
import (
"context"
"errors"
"net/http"
"strings"
"time"
"vctp/internal/auth"
"vctp/server/audit"
"vctp/server/middleware"
"vctp/server/models"
)
const (
authLoginFailureMessage = "invalid username or password"
authLoginRequestTimeout = 30 * time.Second
)
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
}
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)
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, "error", err)
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
return
}
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "ldap_authentication_failed", "username", username, "error", err)
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
return
}
roles := auth.ResolveRoles(identity.Groups, cfg.AuthGroupRoleMappings)
if !auth.HasAnyGroup(identity.Groups, cfg.LDAPGroups) || len(roles) == 0 {
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "group_or_role_denied", "username", username, "group_count", len(identity.Groups), "resolved_roles", roles)
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,
})
}