295 lines
8.7 KiB
Go
295 lines
8.7 KiB
Go
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/middleware"
|
|
"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 TestAuthMeSuccess(t *testing.T) {
|
|
h := &Handler{
|
|
Logger: newTestLogger(),
|
|
Settings: testAuthEnabledSettings(),
|
|
}
|
|
protected := middleware.RequireAuth(newTestLogger(), h.Settings)(http.HandlerFunc(h.AuthMe))
|
|
|
|
tokenSvc, err := auth.NewJWTService(auth.JWTConfig{
|
|
SigningKeyBase64: h.Settings.Values.Settings.AuthJWTSigningKey,
|
|
Issuer: h.Settings.Values.Settings.AuthJWTIssuer,
|
|
Audience: h.Settings.Values.Settings.AuthJWTAudience,
|
|
TokenLifespan: time.Duration(h.Settings.Values.Settings.AuthTokenLifespanMinutes) * time.Minute,
|
|
ClockSkew: time.Duration(h.Settings.Values.Settings.AuthClockSkewSeconds) * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create jwt service: %v", err)
|
|
}
|
|
token, claims, err := tokenSvc.IssueToken("alice", []string{"viewer"}, []string{"cn=vctp-viewers,ou=groups,dc=example,dc=com"})
|
|
if err != nil {
|
|
t.Fatalf("failed to issue token: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rr := httptest.NewRecorder()
|
|
protected.ServeHTTP(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.AuthMeResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if payload.Status != "OK" {
|
|
t.Fatalf("unexpected status: %q", payload.Status)
|
|
}
|
|
if payload.Subject != claims.Subject {
|
|
t.Fatalf("unexpected subject: %q", payload.Subject)
|
|
}
|
|
if payload.Issuer != claims.Issuer || payload.Audience != claims.Audience {
|
|
t.Fatalf("unexpected issuer/audience: %q/%q", payload.Issuer, payload.Audience)
|
|
}
|
|
if payload.TokenID != claims.ID {
|
|
t.Fatalf("unexpected token id: %q", payload.TokenID)
|
|
}
|
|
}
|
|
|
|
func TestAuthMeMissingAuthContext(t *testing.T) {
|
|
h := &Handler{
|
|
Logger: newTestLogger(),
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
|
rr := httptest.NewRecorder()
|
|
h.AuthMe(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMeMethodNotAllowed(t *testing.T) {
|
|
h := &Handler{
|
|
Logger: newTestLogger(),
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/me", nil)
|
|
rr := httptest.NewRecorder()
|
|
h.AuthMe(rr, req)
|
|
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, 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
|
|
}
|
|
}
|