add auth support
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-04-17 13:19:08 +10:00
parent 9a561f3b07
commit ae3e2be89a
22 changed files with 2479 additions and 40 deletions
+146
View File
@@ -0,0 +1,146 @@
package handler
import (
"context"
"errors"
"net/http"
"strings"
"time"
"vctp/internal/auth"
"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 {
writeJSONError(w, http.StatusInternalServerError, "authentication is not configured")
return
}
cfg := h.Settings.Values.Settings
if !cfg.AuthEnabled {
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)
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
return
}
username := strings.TrimSpace(req.Username)
password := req.Password
if username == "" || strings.TrimSpace(password) == "" {
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)
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) {
h.Logger.Warn("auth login rejected", "username", username, "reason", "invalid_credentials")
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
return
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
h.Logger.Warn("auth login ldap timeout", "username", username, "error", err)
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
return
}
h.Logger.Warn("auth login ldap failure", "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 {
h.Logger.Warn("auth login rejected", "username", username, "reason", "group_or_role_denied")
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)
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)
writeJSONError(w, http.StatusInternalServerError, "failed to issue access token")
return
}
h.Logger.Info("auth login successful", "username", subject, "roles", roles)
writeJSON(w, http.StatusOK, models.AuthLoginResponse{
AccessToken: token,
ExpiresAt: claims.ExpiresAt,
TokenType: "Bearer",
})
}
+219
View File
@@ -0,0 +1,219 @@
package handler
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"vctp/internal/auth"
"vctp/internal/settings"
"vctp/server/models"
)
type stubLDAPAuthenticator struct {
identity auth.LDAPIdentity
err error
}
func (s *stubLDAPAuthenticator) AuthenticateAndFetchGroups(_ context.Context, _ string, _ string) (auth.LDAPIdentity, error) {
return s.identity, s.err
}
type stubJWTService struct {
token string
claims auth.Claims
err error
}
func (s *stubJWTService) IssueToken(_ string, _ []string, _ []string) (string, auth.Claims, error) {
return s.token, s.claims, s.err
}
func TestAuthLoginAuthDisabled(t *testing.T) {
h := &Handler{
Logger: newTestLogger(),
Settings: &settings.Settings{Values: &settings.SettingsYML{}},
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`))
rr := httptest.NewRecorder()
h.AuthLogin(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rr.Code)
}
}
func TestAuthLoginInvalidCredentials(t *testing.T) {
restoreFactories := swapAuthFactoriesForTest(
func(_ auth.LDAPConfig) (ldapAuthenticator, error) {
return &stubLDAPAuthenticator{err: auth.ErrLDAPInvalidCredentials}, nil
},
func(_ auth.JWTConfig) (jwtService, error) {
return &stubJWTService{}, nil
},
)
defer restoreFactories()
h := &Handler{
Logger: newTestLogger(),
Settings: testAuthEnabledSettings(),
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`))
rr := httptest.NewRecorder()
h.AuthLogin(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
}
var payload models.ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if payload.Message != authLoginFailureMessage {
t.Fatalf("unexpected error message: %q", payload.Message)
}
}
func TestAuthLoginRejectsUnmappedRoles(t *testing.T) {
restoreFactories := swapAuthFactoriesForTest(
func(_ auth.LDAPConfig) (ldapAuthenticator, error) {
return &stubLDAPAuthenticator{
identity: auth.LDAPIdentity{
Username: "alice",
Groups: []string{"cn=other-group,ou=groups,dc=example,dc=com"},
},
}, nil
},
func(_ auth.JWTConfig) (jwtService, error) {
return &stubJWTService{}, nil
},
)
defer restoreFactories()
h := &Handler{
Logger: newTestLogger(),
Settings: testAuthEnabledSettings(),
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`))
rr := httptest.NewRecorder()
h.AuthLogin(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
}
}
func TestAuthLoginSuccess(t *testing.T) {
restoreFactories := swapAuthFactoriesForTest(
func(_ auth.LDAPConfig) (ldapAuthenticator, error) {
return &stubLDAPAuthenticator{
identity: auth.LDAPIdentity{
Username: "alice",
UserDN: "cn=alice,ou=users,dc=example,dc=com",
Groups: []string{"cn=vctp-admins,ou=groups,dc=example,dc=com"},
},
}, nil
},
func(_ auth.JWTConfig) (jwtService, error) {
return &stubJWTService{
token: "issued-token",
claims: auth.Claims{
ExpiresAt: time.Unix(1_700_000_000, 0).Unix(),
},
}, nil
},
)
defer restoreFactories()
h := &Handler{
Logger: newTestLogger(),
Settings: testAuthEnabledSettings(),
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`))
rr := httptest.NewRecorder()
h.AuthLogin(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String())
}
var payload models.AuthLoginResponse
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if payload.AccessToken != "issued-token" {
t.Fatalf("unexpected token: %q", payload.AccessToken)
}
if payload.TokenType != "Bearer" {
t.Fatalf("unexpected token type: %q", payload.TokenType)
}
}
func TestAuthLoginJWTFactoryFailure(t *testing.T) {
restoreFactories := swapAuthFactoriesForTest(
func(_ auth.LDAPConfig) (ldapAuthenticator, error) {
return &stubLDAPAuthenticator{
identity: auth.LDAPIdentity{
Username: "alice",
Groups: []string{"cn=vctp-admins,ou=groups,dc=example,dc=com"},
},
}, nil
},
func(_ auth.JWTConfig) (jwtService, error) {
return nil, errors.New("jwt init failed")
},
)
defer restoreFactories()
h := &Handler{
Logger: newTestLogger(),
Settings: testAuthEnabledSettings(),
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`))
rr := httptest.NewRecorder()
h.AuthLogin(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rr.Code)
}
}
func testAuthEnabledSettings() *settings.Settings {
cfg := &settings.Settings{Values: &settings.SettingsYML{}}
cfg.Values.Settings.AuthEnabled = true
cfg.Values.Settings.AuthMode = "required"
cfg.Values.Settings.AuthJWTSigningKey = base64.StdEncoding.EncodeToString([]byte("test-signing-key"))
cfg.Values.Settings.AuthTokenLifespanMinutes = 120
cfg.Values.Settings.AuthJWTIssuer = "vctp"
cfg.Values.Settings.AuthJWTAudience = "vctp-api"
cfg.Values.Settings.AuthClockSkewSeconds = 60
cfg.Values.Settings.LDAPBindAddress = "ldaps://ldap.example.com:636"
cfg.Values.Settings.LDAPBaseDN = "dc=example,dc=com"
cfg.Values.Settings.AuthGroupRoleMappings = map[string]string{
"cn=vctp-admins,ou=groups,dc=example,dc=com": "admin",
}
return cfg
}
func swapAuthFactoriesForTest(
ldapFactory func(auth.LDAPConfig) (ldapAuthenticator, error),
jwtFactory func(auth.JWTConfig) (jwtService, error),
) func() {
origLDAPFactory := newLDAPAuthenticator
origJWTFactory := newJWTService
newLDAPAuthenticator = ldapFactory
newJWTService = jwtFactory
return func() {
newLDAPAuthenticator = origLDAPFactory
newJWTService = origJWTFactory
}
}
+7
View File
@@ -18,6 +18,13 @@ func TestMutatingHandlersRejectWrongMethod(t *testing.T) {
path string
call func(*Handler, *httptest.ResponseRecorder, *http.Request)
}{
{
name: "auth login",
path: "/api/auth/login",
call: func(h *Handler, rr *httptest.ResponseRecorder, req *http.Request) {
h.AuthLogin(rr, req)
},
},
{
name: "snapshot force hourly",
path: "/api/snapshots/hourly/force",