diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..632828d --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,31 @@ +name: Codecov + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + + - name: Install dependencies + run: go mod download + + - name: Run tests + run: go test -coverprofile=coverage.txt -coverpkg=./... ./pkg/... ./internal/chain ./internal/manager ./internal/autoconfig + + - name: Upload results to Codecov + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: BeamFoundry/rays-web-security + fail_ci_if_error: true diff --git a/README.md b/README.md index 55ec8ce..8d28375 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # BeamFoundry Rays Web Security 🛡️ +[![beamfoundry](https://rays.bfdy.dev/rays-web-security/rays-web-security-bfdy.jpeg)](https://rays.bfdy.dev/rays-web-security/) + +[![Go Version](https://img.shields.io/badge/Go-1.27+-00ADD8?style=flat&logo=go)](https://golang.org) +[![codecov](https://codecov.io/gh/BeamFoundry/rays-web-security/graph/badge.svg?token=DJWS93PEI8)](https://codecov.io/gh/BeamFoundry/rays-web-security) + **This is an extract of the full documentation available at [rays.bdfy.dev](https://rays.bfdy.dev/rays-web-security/).** **Rays Web Security** brings the declarative power and robust architecture of Spring Security directly to Go. diff --git a/docs/content/docs/4-security-chains/5-pre-auth.md b/docs/content/docs/4-security-chains/5-pre-auth.md new file mode 100644 index 0000000..3a77424 --- /dev/null +++ b/docs/content/docs/4-security-chains/5-pre-auth.md @@ -0,0 +1,146 @@ +--- +title: "Pre-Authentication" +weight: 5 +--- + +# Pre-Authentication + +In enterprise architectures, authentication is often offloaded to an external infrastructure component before the request ever reaches your application: + +* **Reverse Proxies & API Gateways**: NGINX, Apache, Envoy, or Traefik validating client tokens or mutual TLS (mTLS) and injecting identity headers (e.g. `X-Remote-User`, `X-Forwarded-User`). +* **Identity & Access Gateways**: Cloudflare Access, OAuth2-Proxy, AWS ALB, Google Cloud IAP, or CA SiteMinder asserting verified identity. +* **Direct Mutual TLS (mTLS)**: Clients authenticating with an X.509 client certificate directly terminated by the application. + +Rays Web Security provides first-class support for **Pre-Authentication**, inspired by Spring Security's pre-authentication model. + +## Enabling Pre-Authentication + +Enable and configure pre-authentication by invoking `.PreAuth()` on your `HttpSecurityBuilder`: + +```go +package configurations + +import ( + . "github.com/BeamFoundry/rays-web-security/pkg/config" + . "github.com/BeamFoundry/rays/pkg/core" +) + +type SecurityConfig struct { + Configuration +} + +func (this *SecurityConfig) HttpSecurity() *HttpSecurity { + return (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/admin/**").HasRole("ADMIN"). + Paths("/api/**").Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + RolesHeader("X-Remote-Roles"). + RolesDelimiter(","). + And(). + SessionCreationPolicy().Stateless(). + Build() +} + +var _, _ = any(&SecurityConfig{}).(Stereotype) +``` + +## Configuration Options + +The `PreAuth()` builder provides the following methods: + +| Method | Default Value | Description | +| :--- | :--- | :--- | +| `PrincipalHeader(string)` | `"X-Remote-User"` | HTTP request header used to extract the user principal (username/identity). | +| `CredentialsHeader(string)` | `""` (empty) | Optional HTTP header to extract pre-authenticated credentials (e.g. certificate or proxy token). | +| `RolesHeader(string)` | `""` (empty) | Optional HTTP header to extract roles or granted authorities. | +| `RolesDelimiter(string)` | `","` | Delimiter used to split multiple roles in `RolesHeader`. | +| `AllowMtlsFallback(bool)` | `true` | When `true`, falls back to direct mTLS client certificates if no valid header identity is present. | +| `TrustedProxies(...string)` | `[]` (empty) | List of allow-listed proxy identifiers (CommonName or Subject) allowed to forward identity headers. | +| `ExceptionOnMissing(bool)` | `false` | When `true`, immediately rejects requests with `403 Forbidden` if pre-authenticated credentials are missing, rather than proceeding down the filter chain. | + +## Security & Header Spoofing Protection + +Pre-authentication trusts the identity forwarded in HTTP headers. If an untrusted client can send requests directly to your backend, they could forge `X-Remote-User` and impersonate any user. + +Rays Web Security includes built-in safeguards to prevent header spoofing: + +### 1. Mutual Exclusivity Between Headers & mTLS + +When a client establishes a direct connection with a verified mTLS certificate, an untrusted request header cannot override the verified mTLS identity. The framework enforces direct mTLS identity and ignores spoofed user headers. + +### 2. Trusted Proxy Restrictions (`TrustedProxies`) + +When your backend terminates mTLS connections from both reverse proxies and direct clients, you can configure `TrustedProxies`: + +```go +func (this *SecurityConfig) HttpSecurity() *HttpSecurity { + return (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + AnyRequest().Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + TrustedProxies("proxy.internal.net", "CN=gateway.infra"). + AllowMtlsFallback(true). + And(). + Build() +} +``` + +* **Verified Proxy**: If the incoming connection presents a verified mTLS certificate matching `TrustedProxies`, the header `X-Remote-User` is accepted. +* **Direct Client**: If the client connects with their own mTLS certificate (not in `TrustedProxies`), header override is blocked and their own certificate identity is used. +* **Unverified Connection**: Plaintext or unverified connections cannot assert user identity via headers when trusted proxies are configured. + +### 3. Role Stripping on mTLS Fallback + +When authentication falls back to direct mTLS client certificates, any request-supplied header roles (`RolesHeader`) are automatically cleared. Authorities must be derived from the verified certificate or loaded via an `AuthenticationUserDetailsService`. + +## User Details & Authority Mapping + +Once a principal is extracted (from headers or mTLS), authentication is processed by an `AuthenticationProvider` delegating to an `AuthenticationUserDetailsService`: + +```go +type AuthenticationUserDetailsService interface { + LoadUserDetails(token Authentication) (UserDetails, AuthenticationException) +} +``` + +Rays Web Security includes two built-in implementations: + +1. **`PreAuthenticatedGrantedAuthoritiesUserDetailsService`**: Creates a `UserDetails` instance using the principal string and authorities parsed directly from the pre-authentication token / roles header. +2. **`UserDetailsByNameServiceWrapper`**: Adapts an existing `UserDetailsService` (such as a database or in-memory user repository) to load full account details and permissions for the pre-authenticated username. + +By default, `internal/autoconfig` automatically configures a `PreAuthenticatedAuthenticationProvider` and provides a default `AuthenticationUserDetailsService`: +* If a `UserDetailsService` beam is found in the IoC container, it is automatically wrapped using `UserDetailsByNameServiceWrapper`. +* If no `UserDetailsService` beam is present, it defaults to `PreAuthenticatedGrantedAuthoritiesUserDetailsService`. + +### Registering a Custom `AuthenticationUserDetailsService` + +To map pre-authenticated identities to database users or custom permissions, register an `AuthenticationUserDetailsService` Beam in your configuration: + +```go +package configurations + +import ( + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/core" + . "github.com/BeamFoundry/rays/pkg/core" +) + +type PreAuthConfig struct { + Configuration +} + +func (this *PreAuthConfig) PreAuthUserDetailsService( + that struct { + userDetailsService UserDetailsService `@:"Inject"` + }, +) AuthenticationUserDetailsService { + return UserDetailsByNameServiceWrapper{}.New(that.userDetailsService) +} + +var _, _ = any(&PreAuthConfig{}).(Stereotype) +``` diff --git a/docs/static/rays-web-security-bfdy.jpeg b/docs/static/rays-web-security-bfdy.jpeg new file mode 100644 index 0000000..1deeba3 Binary files /dev/null and b/docs/static/rays-web-security-bfdy.jpeg differ diff --git a/internal/autoconfig/default.go b/internal/autoconfig/default.go index e38e32a..569dc21 100644 --- a/internal/autoconfig/default.go +++ b/internal/autoconfig/default.go @@ -8,6 +8,7 @@ import ( . "github.com/BeamFoundry/rays-web-security/internal/manager" + . "github.com/BeamFoundry/rays-web-security/pkg/auth" . "github.com/BeamFoundry/rays-web-security/pkg/core" . "github.com/BeamFoundry/rays-web-security/pkg/password" ) @@ -43,3 +44,28 @@ func (this *DefaultConfiguration) UserDetailsAuthenticationProvider( provider.SetEncoder(that.encoder) return provider } + +func (this *DefaultConfiguration) AuthenticationUserDetailsService( + that struct { + ConditionalOnMissingBeam + userDetailsService Optional[UserDetailsService] `@:"Inject"` + }, +) AuthenticationUserDetailsService { + if uds, ok := that.userDetailsService.Get(); ok { + return UserDetailsByNameServiceWrapper{}.New(uds) + } + return PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() +} + +func (this *DefaultConfiguration) PreAuthenticatedAuthenticationProvider( + that struct { + ConditionalOnMissingBeam + service Optional[AuthenticationUserDetailsService] `@:"Inject"` + }, +) *PreAuthenticatedAuthenticationProvider { + service, ok := that.service.Get() + if !ok { + return nil + } + return PreAuthenticatedAuthenticationProvider{}.New(service) +} diff --git a/internal/autoconfig/default_test.go b/internal/autoconfig/default_test.go new file mode 100644 index 0000000..b7af54e --- /dev/null +++ b/internal/autoconfig/default_test.go @@ -0,0 +1,104 @@ +package autoconfig + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" + + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/core" + . "github.com/BeamFoundry/rays-web-security/pkg/password" + . "github.com/BeamFoundry/rays-web-security/pkg/testing" +) + +func TestAutoconfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal/Autoconfig Suite") +} + +var _ = Describe("DefaultConfiguration", func() { + It("provides default PasswordEncoder", func() { + cfg := &DefaultConfiguration{} + encoder := cfg.PasswordEncoder(struct{ ConditionalOnMissingBeam }{}) + Expect(encoder).NotTo(BeNil()) + }) + + It("provides UserDetailsAuthenticationProvider when service is present or nil when absent", func() { + cfg := &DefaultConfiguration{} + encoder := NoOpPasswordEncoder{}.New() + + // Absent + pNil := cfg.UserDetailsAuthenticationProvider(struct { + ConditionalOnMissingBeam + encoder PasswordEncoder `@:"Inject"` + service Optional[UserDetailsService] `@:"Inject"` + }{ + encoder: encoder, + service: OptionalEmpty[UserDetailsService](), + }) + Expect(pNil).To(BeNil()) + + // Present + uds := InMemoryUserDetailsManager{}.New(encoder) + p := cfg.UserDetailsAuthenticationProvider(struct { + ConditionalOnMissingBeam + encoder PasswordEncoder `@:"Inject"` + service Optional[UserDetailsService] `@:"Inject"` + }{ + encoder: encoder, + service: OptionalOf[UserDetailsService](uds), + }) + Expect(p).NotTo(BeNil()) + }) + + It("provides AuthenticationUserDetailsService wrapping UserDetailsService or defaulting", func() { + cfg := &DefaultConfiguration{} + + // Default (no UserDetailsService) + sDefault := cfg.AuthenticationUserDetailsService(struct { + ConditionalOnMissingBeam + userDetailsService Optional[UserDetailsService] `@:"Inject"` + }{ + userDetailsService: OptionalEmpty[UserDetailsService](), + }) + Expect(sDefault).NotTo(BeNil()) + + // Wrapped UserDetailsService + encoder := NoOpPasswordEncoder{}.New() + uds := InMemoryUserDetailsManager{}.New(encoder) + sWrapped := cfg.AuthenticationUserDetailsService(struct { + ConditionalOnMissingBeam + userDetailsService Optional[UserDetailsService] `@:"Inject"` + }{ + userDetailsService: OptionalOf[UserDetailsService](uds), + }) + Expect(sWrapped).NotTo(BeNil()) + }) + + It("provides PreAuthenticatedAuthenticationProvider when service is present or nil when absent", func() { + cfg := &DefaultConfiguration{} + + // Absent + pNil := cfg.PreAuthenticatedAuthenticationProvider(struct { + ConditionalOnMissingBeam + service Optional[AuthenticationUserDetailsService] `@:"Inject"` + }{ + service: OptionalEmpty[AuthenticationUserDetailsService](), + }) + Expect(pNil).To(BeNil()) + + // Present + service := PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() + p := cfg.PreAuthenticatedAuthenticationProvider(struct { + ConditionalOnMissingBeam + service Optional[AuthenticationUserDetailsService] `@:"Inject"` + }{ + service: OptionalOf[AuthenticationUserDetailsService](service), + }) + Expect(p).NotTo(BeNil()) + }) +}) diff --git a/internal/chain/preauth.go b/internal/chain/preauth.go new file mode 100644 index 0000000..cd1c080 --- /dev/null +++ b/internal/chain/preauth.go @@ -0,0 +1,144 @@ +package chain + +import ( + "context" + "crypto/x509" + "strings" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" + + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/config" + . "github.com/BeamFoundry/rays-web-security/pkg/core" +) + +type PreAuthFilter struct { + Component + Prototype + + authenticationManager AuthenticationManager `@:"Inject"` + securityContextHolder SecurityContextHolder `@:"Inject"` + httpSecurity *HttpSecurity +} + +var _, _ = any(&PreAuthFilter{}).(Stereotype) + +var _ SecurityFilter = &PreAuthFilter{} + +func (this *PreAuthFilter) WithContext(ctx context.Context) Error { + if ct := ctx.Value(PrototypeContextParentInstance); ct != nil { + httpSecurity, ok := ct.(*HttpSecurity) + if !ok { + return NewError("IllegalArgumentException", "Only HttpSecurity may inject this type") + } + if httpSecurity.PreAuth() == nil { + return NewError("IllegalArgumentException", "HttpSecurity must accept PreAuth to use this filter.") + } + this.httpSecurity = httpSecurity + } + return nil +} + +// Authenticate implements [SecurityFilter]. +func (this *PreAuthFilter) Authenticate(handler SecurityHandler) SecurityFilterChainException { + if this.httpSecurity == nil || this.httpSecurity.PreAuth() == nil { + return DefaultSecurityFilterChainException{}.Next() + } + preAuth := this.httpSecurity.PreAuth() + + var principal any + var credentials any + var authorities []GrantedAuthority + + // Check if a verified mTLS certificate is present on the connection + var cert *x509.Certificate + if tlsHandler, ok := handler.(TLSConnectionHandler); ok { + state := tlsHandler.TLSConnectionState() + if state != nil && len(state.PeerCertificates) > 0 && len(state.VerifiedChains) > 0 { + cert = state.PeerCertificates[0] + } + } + + // Determine if headers can be trusted: + // - If trusted proxies are configured, headers are accepted ONLY from a verified allow-listed proxy. + // - If no trusted proxies are configured, header and mTLS modes are mutually exclusive: + // a verified mTLS identity cannot be overridden by an untrusted request header. + allowHeaders := false + if len(preAuth.TrustedProxies()) > 0 { + allowHeaders = preAuth.IsTrustedProxy(cert) + } else { + allowHeaders = cert == nil + } + + // 1. Primary: Extract from Headers (Choice A) when headers are allowed + if allowHeaders { + if headerName := preAuth.PrincipalHeader(); headerName != "" { + if headerVal := handler.Header(headerName); headerVal != "" { + principal = headerVal + } + } + if credHeader := preAuth.CredentialsHeader(); credHeader != "" { + if credVal := handler.Header(credHeader); credVal != "" { + credentials = credVal + } + } + if rolesHeader := preAuth.RolesHeader(); rolesHeader != "" { + if rolesVal := handler.Header(rolesHeader); rolesVal != "" { + delim := preAuth.RolesDelimiter() + if delim == "" { + delim = "," + } + for r := range strings.SplitSeq(rolesVal, delim) { + trimmed := strings.TrimSpace(r) + if trimmed != "" { + authorities = append(authorities, Authority(trimmed)) + } + } + } + } + } + + // 2. Direct mTLS fallback (Choice B): if principal wasn't derived from headers, + // and a verified certificate is present, mTLS fallback is enabled, and cert is not a trusted proxy. + if (principal == nil || principal == "") && cert != nil && preAuth.AllowMtlsFallback() && !preAuth.IsTrustedProxy(cert) { + switch { + case cert.Subject.CommonName != "": + principal = cert.Subject.CommonName + case len(cert.EmailAddresses) > 0: + principal = cert.EmailAddresses[0] + default: + principal = cert.Subject.String() + } + credentials = cert + authorities = nil + } + + // If no principal was found + if principal == nil || principal == "" { + if preAuth.ExceptionOnMissing() { + err := NewError("HttpException", "Pre-authenticated credentials missing") + return DefaultSecurityFilterChainException{}.ReportError(err, 403) + } + return DefaultSecurityFilterChainException{}.Next() + } + + token := PreAuthenticatedAuthenticationToken{}.New(principal, credentials) + if len(authorities) > 0 { + token.Authorities = authorities + } + + auth, authEx := this.authenticationManager.Authenticate(token) + if authEx != nil { + return DefaultSecurityFilterChainException{}.ReportError(authEx, 403) + } + + this.securityContextHolder.GetContext().SetAuthentication(auth) + return DefaultSecurityFilterChainException{}.Next() +} + +// AuthenticationRequired implements [SecurityFilter]. +func (this *PreAuthFilter) AuthenticationRequired(_ SecurityHandler) SecurityFilterChainException { + err := NewError("HttpException", "Pre-authenticated credentials required") + return DefaultSecurityFilterChainException{}.ReportError(err, 403) +} diff --git a/internal/chain/preauth_test.go b/internal/chain/preauth_test.go new file mode 100644 index 0000000..8a01e99 --- /dev/null +++ b/internal/chain/preauth_test.go @@ -0,0 +1,551 @@ +package chain_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "net/url" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/testing" + . "github.com/BeamFoundry/rays/pkg/web" + + _ "github.com/BeamFoundry/rays-web-security/internal/autoconfig" + . "github.com/BeamFoundry/rays-web-security/internal/chain" + + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/config" + . "github.com/BeamFoundry/rays-web-security/pkg/core" + . "github.com/BeamFoundry/rays-web-security/pkg/password" + . "github.com/BeamFoundry/rays-web-security/pkg/testing" +) + +type PreAuthSecurityConfiguration struct { + Configuration + httpSecurityBuilder *HttpSecurityBuilder `@:"Inject"` +} + +func (this *PreAuthSecurityConfiguration) HttpSecurity() *HttpSecurity { + return this.httpSecurityBuilder.Build() +} + +type PreAuthUserDetailsServiceConfiguration struct { + Configuration +} + +func (this *PreAuthUserDetailsServiceConfiguration) PreAuthUserDetailsService() AuthenticationUserDetailsService { + return PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() +} + +type FailingPreAuthUserDetailsServiceConfiguration struct { + Configuration +} + +type failingPreAuthService struct{} + +func (f *failingPreAuthService) LoadUserDetails(token Authentication) (UserDetails, AuthenticationException) { + return nil, BadCredentialsException{}.New("user not found", token) +} + +func (this *FailingPreAuthUserDetailsServiceConfiguration) PreAuthUserDetailsService() AuthenticationUserDetailsService { + return &failingPreAuthService{} +} + +var _ = Describe("PreAuthenticationFilter", func() { + var chain SecurityFilterChain + var app *MockSecurityAppHandler + + BeforeEach(func() { + GetContainer().EnableScanning() + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/admin/**").HasRole("ADMIN"). + Paths("/api/user/**").HasRole("USER"). + Paths("/api/cert/**").Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + CredentialsHeader("X-Remote-Creds"). + RolesHeader("X-Remote-Roles"). + RolesDelimiter(""). // Trigger default delim test + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + chain = GetBeamDefaultInstance[SecurityFilterChain]() + Expect(chain).NotTo(BeNil()) + app = NewMockSecurityAppHandler() + chain.Initialize("primary", app) + }) + + It("authenticates a request carrying principal and role headers and extracts credentials", func() { + u := ValErr(url.Parse("http://www.example.com/api/admin/data")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + handler.SetHeader("X-Remote-User", "admin-user") + handler.SetHeader("X-Remote-Creds", "secret-token") + handler.SetHeader("X-Remote-Roles", "ROLE_ADMIN,ROLE_USER") + + err := app.Execute(handler) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + user, ok := auth.GetPrincipal().(UserDetails) + Expect(ok).To(BeTrue()) + Expect(user.GetUsername()).To(Equal("admin-user")) + }) + + It("rejects access to protected path when roles are insufficient", func() { + u := ValErr(url.Parse("http://www.example.com/api/admin/data")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + handler.SetHeader("X-Remote-User", "regular-user") + handler.SetHeader("X-Remote-Roles", "ROLE_USER") + + err := app.Execute(handler) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("falls back to mTLS certificate when headers are absent and ensures untrusted headers cannot override cert", func() { + u := ValErr(url.Parse("http://www.example.com/api/cert/profile")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "client-cert-user", + }, + } + handler.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + VerifiedChains: [][]*x509.Certificate{{cert}}, + } + // Attempting to override identity or inject header roles during cert authentication should be ignored + handler.SetHeader("X-Remote-User", "spoofed-admin") + handler.SetHeader("X-Remote-Roles", "ROLE_USER") + + err := app.Execute(handler) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + Expect(auth.GetAuthorities()).To(BeEmpty()) // Authorities from header were cleared + user, ok := auth.GetPrincipal().(UserDetails) + Expect(ok).To(BeTrue()) + Expect(user.GetUsername()).To(Equal("client-cert-user")) + Expect(user.GetUsername()).NotTo(Equal("spoofed-admin")) + }) + + It("rejects mTLS fallback when request-supplied roles in headers are needed for role-protected path", func() { + u := ValErr(url.Parse("http://www.example.com/api/user/profile")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "client-cert-user", + }, + } + handler.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + VerifiedChains: [][]*x509.Certificate{{cert}}, + } + handler.SetHeader("X-Remote-Roles", "ROLE_USER") + + err := app.Execute(handler) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("falls back to mTLS email address when CommonName is empty", func() { + u := ValErr(url.Parse("http://www.example.com/api/cert/profile")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + + cert := &x509.Certificate{ + EmailAddresses: []string{"cert-email@example.com"}, + } + handler.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + VerifiedChains: [][]*x509.Certificate{{cert}}, + } + + err := app.Execute(handler) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + user, ok := auth.GetPrincipal().(UserDetails) + Expect(ok).To(BeTrue()) + Expect(user.GetUsername()).To(Equal("cert-email@example.com")) + }) + + It("falls back to mTLS Subject.String() when CommonName and EmailAddresses are empty", func() { + u := ValErr(url.Parse("http://www.example.com/api/cert/profile")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + Organization: []string{"AcmeCorp"}, + }, + } + handler.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + VerifiedChains: [][]*x509.Certificate{{cert}}, + } + + err := app.Execute(handler) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + user, ok := auth.GetPrincipal().(UserDetails) + Expect(ok).To(BeTrue()) + Expect(user.GetUsername()).To(Equal(cert.Subject.String())) + }) + + It("ignores mTLS certificate when VerifiedChains is empty", func() { + u := ValErr(url.Parse("http://www.example.com/api/cert/profile")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "unverified-client", + }, + } + handler.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + VerifiedChains: nil, // unverified chain + } + + err := app.Execute(handler) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("returns 403 when unauthenticated request accesses restricted path", func() { + u := ValErr(url.Parse("http://www.example.com/api/admin/data")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + + err := app.Execute(handler) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) +}) + +var _ = Describe("PreAuthFilter Edge Cases and Additional Capabilities", func() { + It("returns error when ExceptionOnMissing is true and header is missing", func() { + GetContainer().EnableScanning() + + builder := (&HttpSecurityBuilder{}). + PreAuth(). + PrincipalHeader("X-Remote-User"). + ExceptionOnMissing(true). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + chain := GetBeamDefaultInstance[SecurityFilterChain]() + app := NewMockSecurityAppHandler() + chain.Initialize("primary", app) + + u := ValErr(url.Parse("http://www.example.com/any")) + handler := NewMockSecurityHandler(HttpMethods.GET, u) + err := app.Execute(handler) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("returns Next when PreAuth is nil on HttpSecurity", func() { + filter := &PreAuthFilter{} + ex := filter.Authenticate(nil) + Expect(ex.Action()).To(Equal(DefaultSecurityFilterAction{}.New().NEXT.Name())) + }) + + It("returns 403 in AuthenticationRequired", func() { + filter := &PreAuthFilter{} + ex := filter.AuthenticationRequired(nil) + var dex *DefaultSecurityFilterChainException + Expect(errors.As(ex, &dex)).To(BeTrue()) + Expect(dex.Status()).To(Equal(403)) + }) + + It("returns error in WithContext if parent is not HttpSecurity", func() { + filter := &PreAuthFilter{} + ctx := context.WithValue(context.Background(), PrototypeContextParentInstance, "not-http-security") + err := filter.WithContext(ctx) + Expect(err).To(HaveOccurred()) + }) + + It("does not include PreAuthFilter in HttpSecurity filters when PreAuth is not configured", func() { + GetContainer().EnableScanning() + builder := (&HttpSecurityBuilder{}).AnyRequest().PermitAll() + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + httpSecurity := GetBeamDefaultInstance[*HttpSecurity]() + Expect(httpSecurity).NotTo(BeNil()) + Expect(httpSecurity.PreAuth()).To(BeNil()) + + for _, filter := range httpSecurity.Filters() { + _, isPreAuth := filter.(*PreAuthFilter) + Expect(isPreAuth).To(BeFalse(), "PreAuthFilter should NOT be loaded when PreAuth is not configured") + } + }) + + It("handles non-validation error in FormLoginFilter.BindErrorMessage", func() { + filter := &FormLoginFilter{} + data := filter.BindErrorMessage("myform", errors.New("generic bind error")) + Expect(data["Error"]).To(Equal("generic bind error")) + }) + + It("returns 403 when AuthenticationManager fails authentication", func() { + GetContainer().EnableScanning() + AddBeamFor(&FailingPreAuthUserDetailsServiceConfiguration{}) + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/test/**").PermitAll(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + c := GetBeamDefaultInstance[SecurityFilterChain]() + a := NewMockSecurityAppHandler() + c.Initialize("primary", a) + + u := ValErr(url.Parse("http://www.example.com/api/test")) + h := NewMockSecurityHandler(HttpMethods.GET, u) + h.SetHeader("X-Remote-User", "unknown_user") + err := a.Execute(h) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("accepts pre-auth headers from a verified trusted proxy", func() { + GetContainer().EnableScanning() + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/proxy/**").Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + TrustedProxies("proxy.internal"). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + c := GetBeamDefaultInstance[SecurityFilterChain]() + a := NewMockSecurityAppHandler() + c.Initialize("primary", a) + + u := ValErr(url.Parse("http://www.example.com/api/proxy/data")) + h := NewMockSecurityHandler(HttpMethods.GET, u) + proxyCert := &x509.Certificate{ + Subject: pkix.Name{CommonName: "proxy.internal"}, + } + h.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{proxyCert}, + VerifiedChains: [][]*x509.Certificate{{proxyCert}}, + } + h.SetHeader("X-Remote-User", "forwarded-alice") + + err := a.Execute(h) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + Expect(auth.GetPrincipal().(UserDetails).GetUsername()).To(Equal("forwarded-alice")) + }) + + It("rejects pre-auth headers when connection is not from a trusted proxy", func() { + GetContainer().EnableScanning() + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/proxy/**").Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + TrustedProxies("proxy.internal"). + AllowMtlsFallback(false). + ExceptionOnMissing(true). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + c := GetBeamDefaultInstance[SecurityFilterChain]() + a := NewMockSecurityAppHandler() + c.Initialize("primary", a) + + // Request without cert (untrusted connection) + u := ValErr(url.Parse("http://www.example.com/api/proxy/data")) + h := NewMockSecurityHandler(HttpMethods.GET, u) + h.SetHeader("X-Remote-User", "spoofed-user") + + err := a.Execute(h) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("rejects request with a trusted proxy certificate when no principal header is provided on an authenticated path", func() { + GetContainer().EnableScanning() + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/proxy/**").Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + TrustedProxies("proxy.internal"). + AllowMtlsFallback(true). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + c := GetBeamDefaultInstance[SecurityFilterChain]() + a := NewMockSecurityAppHandler() + c.Initialize("primary", a) + + u := ValErr(url.Parse("http://www.example.com/api/proxy/data")) + h := NewMockSecurityHandler(HttpMethods.GET, u) + proxyCert := &x509.Certificate{ + Subject: pkix.Name{CommonName: "proxy.internal"}, + } + h.Req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{proxyCert}, + VerifiedChains: [][]*x509.Certificate{{proxyCert}}, + } + + err := a.Execute(h) + Expect(err).To(HaveOccurred()) + var ex *DefaultSecurityFilterChainException + Expect(errors.As(err, &ex)).To(BeTrue()) + Expect(ex.Status()).To(Equal(403)) + }) + + It("allows overriding default AuthenticationUserDetailsService with a custom beam", func() { + GetContainer().EnableScanning() + AddBeamFor(&PreAuthUserDetailsServiceConfiguration{}) + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/custom/**").Authenticated(). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + c := GetBeamDefaultInstance[SecurityFilterChain]() + a := NewMockSecurityAppHandler() + c.Initialize("primary", a) + + u := ValErr(url.Parse("http://www.example.com/api/custom/resource")) + h := NewMockSecurityHandler(HttpMethods.GET, u) + h.SetHeader("X-Remote-User", "custom-alice") + + err := a.Execute(h) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + Expect(auth.GetPrincipal().(UserDetails).GetUsername()).To(Equal("custom-alice")) + }) + + It("automatically wraps UserDetailsService bean when present in the container", func() { + GetContainer().EnableScanning() + uds := InMemoryUserDetailsManager{}.New(NoOpPasswordEncoder{}.New()) + _ = uds.NewUser(). + WithUsername("db-user"). + WithPassword("secret"). + WithAuthorities([]GrantedAuthority{Authority("ROLE_USER")}). + Build() + AddBeamFor(uds) + + builder := (&HttpSecurityBuilder{}). + AuthorizedHTTPRequests(). + Paths("/api/db/**").HasRole("USER"). + And(). + PreAuth(). + PrincipalHeader("X-Remote-User"). + And(). + SessionCreationPolicy().Stateless() + + AddBeamFor(builder) + AddBeamFor(&PreAuthSecurityConfiguration{}) + Expect(GetContainer().Initialize()).NotTo(HaveOccurred()) + + c := GetBeamDefaultInstance[SecurityFilterChain]() + a := NewMockSecurityAppHandler() + c.Initialize("primary", a) + + u := ValErr(url.Parse("http://www.example.com/api/db/profile")) + h := NewMockSecurityHandler(HttpMethods.GET, u) + h.SetHeader("X-Remote-User", "db-user") + + err := a.Execute(h) + Expect(err).NotTo(HaveOccurred()) + + contextHolder := GetBeamDefaultInstance[SecurityContextHolder]() + auth := contextHolder.GetContext().GetAuthentication() + Expect(auth.IsAuthenticated()).To(BeTrue()) + Expect(auth.GetPrincipal().(UserDetails).GetUsername()).To(Equal("db-user")) + + // Also verify unmapped db-user fails authentication + hBad := NewMockSecurityHandler(HttpMethods.GET, u) + hBad.SetHeader("X-Remote-User", "missing-db-user") + errBad := a.Execute(hBad) + Expect(errBad).To(HaveOccurred()) + }) +}) diff --git a/internal/manager/preauth_provider.go b/internal/manager/preauth_provider.go new file mode 100644 index 0000000..49b1b92 --- /dev/null +++ b/internal/manager/preauth_provider.go @@ -0,0 +1,74 @@ +package manager + +import ( + "reflect" + + . "github.com/BeamFoundry/rays/pkg/lang" + + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/core" +) + +// PreAuthenticatedAuthenticationProvider processes [PreAuthenticatedAuthenticationToken] instances. +// +// It delegates user retrieval to an [AuthenticationUserDetailsService] and validates account status. +type PreAuthenticatedAuthenticationProvider struct { + service AuthenticationUserDetailsService +} + +var _ AuthenticationProvider = &PreAuthenticatedAuthenticationProvider{} + +// New() creates a new [PreAuthenticatedAuthenticationProvider]. +func (this PreAuthenticatedAuthenticationProvider) New(service AuthenticationUserDetailsService) *PreAuthenticatedAuthenticationProvider { + this.service = service + return &this +} + +// Supports() implements [AuthenticationProvider.Supports]. +func (this *PreAuthenticatedAuthenticationProvider) Supports(authentication Authentication) bool { + return reflect.TypeOf(authentication) == reflect.TypeFor[*PreAuthenticatedAuthenticationToken]() +} + +// Authenticate() implements [AuthenticationProvider.Authenticate]. +func (this *PreAuthenticatedAuthenticationProvider) Authenticate(authentication Authentication) (Authentication, AuthenticationException) { + token, ok := authentication.(*PreAuthenticatedAuthenticationToken) + if !ok || token.GetPrincipal() == nil || token.GetPrincipal() == "" { + return nil, BadCredentialsException{}.New("PreAuthenticatedAuthenticationProvider: No pre-authenticated principal found", authentication) + } + + if !reflect.ValueOf(this.service).IsValid() { + return nil, InternalAuthenticationException{}.New("AuthenticationUserDetailsService is not available", authentication) + } + + user, loadErr := this.service.LoadUserDetails(token) + if loadErr != nil { + return nil, loadErr + } + if user == nil { + return nil, BadCredentialsException{}.New("PreAuthenticatedAuthenticationProvider: UserDetails is nil", authentication) + } + + err := Try( + func() { + _ = user.IsExpired() && RaiseErr(AccountExpiredException{}.New("UserDetailsService: User is expired", authentication)) + _ = user.IsCredentialExpired() && RaiseErr(CredentialsExpiredException{}.New("UserDetailsService: User credentials have expired", authentication)) + _ = user.IsLocked() && RaiseErr(LockedException{}.New("UserDetailsService: User is locked", authentication)) + _ = user.IsDisabled() && RaiseErr(DisabledException{}.New("UserDetailsService: User is disabled", authentication)) + }, + Catch(func(e AuthenticationException) Error { + return e + }), + ) + if exception, ok := err.(AuthenticationException); ok { + return nil, exception + } + + authorities := user.GetAuthorities() + if len(authorities) == 0 && len(token.GetAuthorities()) > 0 { + authorities = token.GetAuthorities() + } + + authenticated := PreAuthenticatedAuthenticationToken{}.NewAuthenticated(user, nil, authorities) + token.EraseCredentials() + return authenticated, nil +} diff --git a/internal/manager/preauth_test.go b/internal/manager/preauth_test.go new file mode 100644 index 0000000..430c8ff --- /dev/null +++ b/internal/manager/preauth_test.go @@ -0,0 +1,146 @@ +package manager_test + +import ( + . "github.com/BeamFoundry/rays-web-security/internal/manager" + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/core" + . "github.com/BeamFoundry/rays-web-security/pkg/password" + . "github.com/BeamFoundry/rays-web-security/pkg/testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockFailingPreAuthUserService struct { + err AuthenticationException + returnNil bool +} + +//nolint:nilnil // testing nil user handling +func (m *mockFailingPreAuthUserService) LoadUserDetails(_ Authentication) (UserDetails, AuthenticationException) { + if m.returnNil { + return nil, nil + } + return nil, m.err +} + +var _ = Describe("PreAuthenticatedAuthenticationProvider", func() { + It("authenticates a valid pre-authenticated token and copies token authorities if user has none", func() { + service := PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() + provider := PreAuthenticatedAuthenticationProvider{}.New(service) + + token := PreAuthenticatedAuthenticationToken{}.New("alice", "cert") + token.Authorities = []GrantedAuthority{Authority("ROLE_ADMIN")} + + Expect(provider.Supports(token)).To(BeTrue()) + Expect(provider.Supports(UsernamePasswordAuthenticationToken{}.New("a", "b"))).To(BeFalse()) + + auth, err := provider.Authenticate(token) + Expect(err).NotTo(HaveOccurred()) + Expect(auth.IsAuthenticated()).To(BeTrue()) + Expect(auth.GetPrincipal()).NotTo(BeNil()) + Expect(auth.GetCredentials()).To(BeNil()) + Expect(token.GetCredentials()).To(BeNil()) + Expect(auth.GetAuthorities()).To(Equal([]GrantedAuthority{Authority("ROLE_ADMIN")})) + }) + + It("preserves user authorities when user details already has authorities", func() { + manager := InMemoryUserDetailsManager{}.New(NoOpPasswordEncoder{}.New()) + _ = manager.NewUser(). + WithUsername("bob"). + WithPassword("secret"). + WithAuthorities([]GrantedAuthority{Authority("ROLE_USER")}). + Build() + + wrapper := UserDetailsByNameServiceWrapper{}.New(manager) + provider := PreAuthenticatedAuthenticationProvider{}.New(wrapper) + + token := PreAuthenticatedAuthenticationToken{}.New("bob", "cert") + token.Authorities = []GrantedAuthority{Authority("ROLE_EXTERNAL")} + + auth, err := provider.Authenticate(token) + Expect(err).NotTo(HaveOccurred()) + Expect(auth.GetAuthorities()).To(Equal([]GrantedAuthority{Authority("ROLE_USER")})) + }) + + It("copies token authorities when user details has no authorities", func() { + manager := InMemoryUserDetailsManager{}.New(NoOpPasswordEncoder{}.New()) + _ = manager.NewUser(). + WithUsername("bob"). + WithPassword("secret"). + Build() // No authorities assigned to user + + wrapper := UserDetailsByNameServiceWrapper{}.New(manager) + provider := PreAuthenticatedAuthenticationProvider{}.New(wrapper) + + token := PreAuthenticatedAuthenticationToken{}.New("bob", "cert") + token.Authorities = []GrantedAuthority{Authority("ROLE_EXTERNAL")} + + auth, err := provider.Authenticate(token) + Expect(err).NotTo(HaveOccurred()) + Expect(auth.GetAuthorities()).To(Equal([]GrantedAuthority{Authority("ROLE_EXTERNAL")})) + }) + + It("rejects token with missing principal or unsupported token type", func() { + service := PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() + provider := PreAuthenticatedAuthenticationProvider{}.New(service) + + token := PreAuthenticatedAuthenticationToken{}.New("", "") + _, err := provider.Authenticate(token) + Expect(err).To(HaveOccurred()) + + tokenNil := PreAuthenticatedAuthenticationToken{}.New(nil, "") + _, err = provider.Authenticate(tokenNil) + Expect(err).To(HaveOccurred()) + + unsupported := UsernamePasswordAuthenticationToken{}.New("user", "pass") + _, err = provider.Authenticate(unsupported) + Expect(err).To(HaveOccurred()) + }) + + It("fails when service is invalid", func() { + provider := PreAuthenticatedAuthenticationProvider{} + token := PreAuthenticatedAuthenticationToken{}.New("alice", "") + _, err := provider.Authenticate(token) + Expect(err).To(HaveOccurred()) + }) + + It("propagates load error from service", func() { + mockErr := BadCredentialsException{}.New("custom load error", nil) + provider := PreAuthenticatedAuthenticationProvider{}.New(&mockFailingPreAuthUserService{err: mockErr}) + token := PreAuthenticatedAuthenticationToken{}.New("alice", "") + _, err := provider.Authenticate(token) + Expect(err).To(Equal(mockErr)) + }) + + It("fails when service returns nil UserDetails", func() { + provider := PreAuthenticatedAuthenticationProvider{}.New(&mockFailingPreAuthUserService{returnNil: true}) + token := PreAuthenticatedAuthenticationToken{}.New("alice", "") + _, err := provider.Authenticate(token) + Expect(err).To(HaveOccurred()) + }) + + It("checks account status flags and raises appropriate exceptions", func() { + testAccountStatus := func(modify func(b *UserBuilder[*InMemoryUserDetailsManager])) { + manager := InMemoryUserDetailsManager{}.New(NoOpPasswordEncoder{}.New()) + builder := manager.NewUser(). + WithUsername("statususer"). + WithPassword("secret"). + WithAuthorities([]GrantedAuthority{Authority("ROLE_USER")}) + modify(builder) + _ = builder.Build() + + wrapper := UserDetailsByNameServiceWrapper{}.New(manager) + provider := PreAuthenticatedAuthenticationProvider{}.New(wrapper) + token := PreAuthenticatedAuthenticationToken{}.New("statususer", "") + + _, err := provider.Authenticate(token) + Expect(err).To(HaveOccurred()) + } + + testAccountStatus(func(b *UserBuilder[*InMemoryUserDetailsManager]) { b.SetAccountExpired(true) }) + testAccountStatus(func(b *UserBuilder[*InMemoryUserDetailsManager]) { b.SetCredentialsExpired(true) }) + testAccountStatus(func(b *UserBuilder[*InMemoryUserDetailsManager]) { b.SetLocked(true) }) + testAccountStatus(func(b *UserBuilder[*InMemoryUserDetailsManager]) { b.SetDisabled(true) }) + }) +}) diff --git a/pkg/auth/preauth_services.go b/pkg/auth/preauth_services.go new file mode 100644 index 0000000..95b854f --- /dev/null +++ b/pkg/auth/preauth_services.go @@ -0,0 +1,78 @@ +package auth + +import ( + . "github.com/BeamFoundry/rays-web-security/pkg/core" +) + +// UserDetailsByNameServiceWrapper wraps a [UserDetailsService] to implement [AuthenticationUserDetailsService]. +type UserDetailsByNameServiceWrapper struct { + userDetailsService UserDetailsService +} + +var _ AuthenticationUserDetailsService = &UserDetailsByNameServiceWrapper{} + +// New() creates a new [UserDetailsByNameServiceWrapper]. +func (this UserDetailsByNameServiceWrapper) New(service UserDetailsService) *UserDetailsByNameServiceWrapper { + this.userDetailsService = service + return &this +} + +// LoadUserDetails() implements [AuthenticationUserDetailsService.LoadUserDetails]. +func (this *UserDetailsByNameServiceWrapper) LoadUserDetails(token Authentication) (UserDetails, AuthenticationException) { + username := token.GetName() + switch p := token.GetPrincipal().(type) { + case UserDetails: + return p, nil + case string: + username = p + default: + } + + user := this.userDetailsService.LoadUserByUsername(username) + if user == nil { + return nil, BadCredentialsException{}.New("User not found: "+username, token) + } + return user, nil +} + +type preAuthUserDetails struct { + username string + authorities []GrantedAuthority +} + +func (u *preAuthUserDetails) GetUsername() string { return u.username } +func (u *preAuthUserDetails) GetPassword() string { return "" } +func (u *preAuthUserDetails) GetAuthorities() []GrantedAuthority { return u.authorities } +func (u *preAuthUserDetails) IsExpired() bool { return false } +func (u *preAuthUserDetails) IsCredentialExpired() bool { return false } +func (u *preAuthUserDetails) IsDisabled() bool { return false } +func (u *preAuthUserDetails) IsLocked() bool { return false } + +var _ UserDetails = &preAuthUserDetails{} + +// PreAuthenticatedGrantedAuthoritiesUserDetailsService creates [UserDetails] based solely +// on the authorities and principal already attached to the [Authentication] token (e.g. from upstream proxy headers). +type PreAuthenticatedGrantedAuthoritiesUserDetailsService struct{} + +var _ AuthenticationUserDetailsService = &PreAuthenticatedGrantedAuthoritiesUserDetailsService{} + +// New() creates a new [PreAuthenticatedGrantedAuthoritiesUserDetailsService]. +func (this PreAuthenticatedGrantedAuthoritiesUserDetailsService) New() *PreAuthenticatedGrantedAuthoritiesUserDetailsService { + return &this +} + +// LoadUserDetails() implements [AuthenticationUserDetailsService.LoadUserDetails]. +func (this *PreAuthenticatedGrantedAuthoritiesUserDetailsService) LoadUserDetails(token Authentication) (UserDetails, AuthenticationException) { + username := token.GetName() + switch p := token.GetPrincipal().(type) { + case string: + username = p + case UserDetails: + return p, nil + default: + } + return &preAuthUserDetails{ + username: username, + authorities: token.GetAuthorities(), + }, nil +} diff --git a/pkg/auth/preauth_test.go b/pkg/auth/preauth_test.go new file mode 100644 index 0000000..39a52e4 --- /dev/null +++ b/pkg/auth/preauth_test.go @@ -0,0 +1,108 @@ +package auth_test + +import ( + . "github.com/BeamFoundry/rays-web-security/pkg/auth" + . "github.com/BeamFoundry/rays-web-security/pkg/core" + . "github.com/BeamFoundry/rays-web-security/pkg/testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PreAuthenticatedAuthenticationToken", func() { + It("can be used unauthenticated", func() { + token := PreAuthenticatedAuthenticationToken{}.New("testuser", "testcredentials") + Expect(token.IsAuthenticated()).To(BeFalse()) + Expect(token.GetPrincipal()).To(Equal("testuser")) + Expect(token.GetCredentials()).To(Equal("testcredentials")) + Expect(token.GetName()).NotTo(BeEmpty()) + }) + + It("can be used authenticated", func() { + authorities := []GrantedAuthority{Authority("ROLE_USER"), Authority("ROLE_ADMIN")} + token := PreAuthenticatedAuthenticationToken{}.NewAuthenticated("testuser", "testcredentials", authorities) + Expect(token.IsAuthenticated()).To(BeTrue()) + Expect(token.GetPrincipal()).To(Equal("testuser")) + Expect(token.GetCredentials()).To(Equal("testcredentials")) + Expect(token.GetAuthorities()).To(Equal(authorities)) + + token.EraseCredentials() + Expect(token.GetCredentials()).To(BeNil()) + }) +}) + +var _ = Describe("UserDetailsByNameServiceWrapper", func() { + It("loads user details by username string", func() { + manager := InMemoryUserDetailsManager{}.New(DefaultPasswordEncoder) + _ = manager.NewUser(). + WithUsername("john"). + WithPassword("secret"). + WithAuthorities([]GrantedAuthority{Authority("ROLE_ADMIN")}).Build() + + wrapper := UserDetailsByNameServiceWrapper{}.New(manager) + token := PreAuthenticatedAuthenticationToken{}.New("john", "") + + details, err := wrapper.LoadUserDetails(token) + Expect(err).NotTo(HaveOccurred()) + Expect(details.GetUsername()).To(Equal("john")) + Expect(details.GetAuthorities()).To(Equal([]GrantedAuthority{Authority("ROLE_ADMIN")})) + }) + + It("loads user details when principal is already UserDetails", func() { + manager := InMemoryUserDetailsManager{}.New(DefaultPasswordEncoder) + user := manager.NewUser(). + WithUsername("existing"). + WithPassword("secret"). + WithAuthorities([]GrantedAuthority{Authority("ROLE_USER")}).Build() + + wrapper := UserDetailsByNameServiceWrapper{}.New(manager) + token := PreAuthenticatedAuthenticationToken{}.New(user, "") + + details, err := wrapper.LoadUserDetails(token) + Expect(err).NotTo(HaveOccurred()) + Expect(details.GetUsername()).To(Equal("existing")) + }) + + It("returns error if user not found", func() { + manager := InMemoryUserDetailsManager{}.New(DefaultPasswordEncoder) + wrapper := UserDetailsByNameServiceWrapper{}.New(manager) + token := PreAuthenticatedAuthenticationToken{}.New("unknown", "") + + details, err := wrapper.LoadUserDetails(token) + Expect(err).To(HaveOccurred()) + Expect(details).To(BeNil()) + }) +}) + +var _ = Describe("PreAuthenticatedGrantedAuthoritiesUserDetailsService", func() { + It("builds UserDetails from token authorities without database lookup", func() { + service := PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() + token := PreAuthenticatedAuthenticationToken{}.New("proxyuser", "") + token.Authorities = []GrantedAuthority{Authority("ROLE_USER"), Authority("ROLE_API")} + + details, err := service.LoadUserDetails(token) + Expect(err).NotTo(HaveOccurred()) + Expect(details.GetUsername()).To(Equal("proxyuser")) + Expect(details.GetPassword()).To(BeEmpty()) + Expect(details.GetAuthorities()).To(Equal([]GrantedAuthority{Authority("ROLE_USER"), Authority("ROLE_API")})) + Expect(details.IsExpired()).To(BeFalse()) + Expect(details.IsCredentialExpired()).To(BeFalse()) + Expect(details.IsDisabled()).To(BeFalse()) + Expect(details.IsLocked()).To(BeFalse()) + }) + + It("returns existing UserDetails if principal is already UserDetails", func() { + manager := InMemoryUserDetailsManager{}.New(DefaultPasswordEncoder) + user := manager.NewUser(). + WithUsername("alice"). + WithPassword(""). + WithAuthorities([]GrantedAuthority{Authority("ROLE_USER")}).Build() + + service := PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New() + token := PreAuthenticatedAuthenticationToken{}.New(user, "") + + details, err := service.LoadUserDetails(token) + Expect(err).NotTo(HaveOccurred()) + Expect(details.GetUsername()).To(Equal("alice")) + }) +}) diff --git a/pkg/auth/user_authtoken.go b/pkg/auth/user_authtoken.go index eeaf8bd..80cfdf6 100644 --- a/pkg/auth/user_authtoken.go +++ b/pkg/auth/user_authtoken.go @@ -125,8 +125,45 @@ func (this *RememberMeAuthenticationToken) GetCredentials() any { return "" } +// PreAuthenticatedAuthenticationToken is an implementation of [Authentication] representing +// a pre-authenticated principal (e.g. from upstream reverse proxy headers or TLS client certificates). +type PreAuthenticatedAuthenticationToken struct { + credentials any + AbstractAuthenticationToken +} + +var _ Authentication = &PreAuthenticatedAuthenticationToken{} + +// New() is the constructor for an unauthenticated [PreAuthenticatedAuthenticationToken]. +func (this PreAuthenticatedAuthenticationToken) New(principal, credentials any) *PreAuthenticatedAuthenticationToken { + this.setPrincipal(principal) + this.credentials = credentials + this.setAuthenticated(false) + return &this +} + +// NewAuthenticated() is the constructor for an authenticated [PreAuthenticatedAuthenticationToken]. +func (this PreAuthenticatedAuthenticationToken) NewAuthenticated(principal, credentials any, authorities []GrantedAuthority) *PreAuthenticatedAuthenticationToken { + this.setPrincipal(principal) + this.credentials = credentials + this.Authorities = authorities + this.setAuthenticated(true) + return &this +} + +// GetCredentials() implements [Authentication.GetCredentials]. +func (this *PreAuthenticatedAuthenticationToken) GetCredentials() any { + return this.credentials +} + +// EraseCredentials() implements [CredentialsContainer.EraseCredentials]. +func (this *PreAuthenticatedAuthenticationToken) EraseCredentials() { + this.credentials = nil +} + func init() { gob.Register(&AbstractAuthenticationToken{}) gob.Register(&RememberMeAuthenticationToken{}) gob.Register(&UsernamePasswordAuthenticationToken{}) + gob.Register(&PreAuthenticatedAuthenticationToken{}) } diff --git a/pkg/config/custom_filter_test.go b/pkg/config/custom_filter_test.go index 24b8e44..d192655 100644 --- a/pkg/config/custom_filter_test.go +++ b/pkg/config/custom_filter_test.go @@ -195,4 +195,14 @@ var _ = Describe("AddCustomSecurityFilter", func() { } Expect(count).To(Equal(1)) }) + + It("injects preAuthFilter before other filters in PostConstruct", func() { + filter := &AlwaysAcceptSecurityFilter{id: 99} + hs := (&HttpSecurityBuilder{}).Build() + hs.SetInjectedFiltersForTest(map[string]SecurityFilter{ + "preAuthFilter": filter, + }) + Expect(hs.PostConstruct()).NotTo(HaveOccurred()) + Expect(hs.Filters()).To(ContainElement(filter)) + }) }) diff --git a/pkg/config/export_test.go b/pkg/config/export_test.go new file mode 100644 index 0000000..727f606 --- /dev/null +++ b/pkg/config/export_test.go @@ -0,0 +1,15 @@ +package config + +import ( + . "github.com/BeamFoundry/rays-web-security/pkg/core" +) + +// SetInjectedFiltersForTest allows test suites to inject filters directly for PostConstruct testing. +func (this *HttpSecurity) SetInjectedFiltersForTest(filters map[string]SecurityFilter) { + this.injectedFilters = filters +} + +// SetTrustedProxiesForTest allows test suites to set trusted proxies directly, including empty entries. +func (this *PreAuth) SetTrustedProxiesForTest(proxies []string) { + this.trustedProxies = proxies +} diff --git a/pkg/config/http_security.go b/pkg/config/http_security.go index e803310..4767ed5 100644 --- a/pkg/config/http_security.go +++ b/pkg/config/http_security.go @@ -21,6 +21,7 @@ type HttpSecurity struct { httpPolicy *HttpPolicy csrf *CsrfEnabler formLogin *FormLogin + preAuth *PreAuth filters []SecurityFilter customFilterNames []string securityFilters map[string]any @@ -40,6 +41,9 @@ func (this *HttpSecurity) PostConstruct() Error { this.filters = append(this.filters, filter) } + if filter, ok := this.injectedFilters["preAuthFilter"]; ok { + addFilter(filter) + } for _, name := range this.customFilterNames { if filter, ok := this.injectedFilters[name]; ok { addFilter(filter) @@ -47,7 +51,7 @@ func (this *HttpSecurity) PostConstruct() Error { } var remaining []string for name := range this.injectedFilters { - if name != "basicAuthFilter" && name != "formLoginFilter" && !slices.Contains(this.customFilterNames, name) { + if name != "basicAuthFilter" && name != "formLoginFilter" && name != "preAuthFilter" && !slices.Contains(this.customFilterNames, name) { remaining = append(remaining, name) } } @@ -89,6 +93,11 @@ func (this *HttpSecurity) FormLogin() *FormLogin { return this.formLogin } +// PreAuth() returns the [PreAuth] object, if configured. +func (this *HttpSecurity) PreAuth() *PreAuth { + return this.preAuth +} + // SessionCreationPolicy() returns the SessionCreationPolicy configured. func (this *HttpSecurity) SessionCreationPolicy() SessionCreationPolicy { return this.session @@ -156,6 +165,11 @@ func (this *HttpSecurityBuilder) FormLogin() *FormLoginConfigurer { return FormLoginConfigurer{}.newConfigurer(this) } +// PreAuth() enables and lets you configure the Pre-Authentication process. +func (this *HttpSecurityBuilder) PreAuth() *PreAuthConfigurer { + return PreAuthConfigurer{}.newConfigurer(this) +} + // AddCustomFilter() lets you add a custom [SecurityFilter] that will be evaluated by the [SecurityFilterChain]. // They will be evaluated before global ones like for BasicAuth or FormLogin. // diff --git a/pkg/config/preauth.go b/pkg/config/preauth.go new file mode 100644 index 0000000..2f8a4c2 --- /dev/null +++ b/pkg/config/preauth.go @@ -0,0 +1,152 @@ +package config + +import ( + "crypto/x509" +) + +// PreAuth holds the configuration parameters for pre-authentication. +type PreAuth struct { + principalHeader string + credentialsHeader string + rolesHeader string + rolesDelimiter string + trustedProxies []string + allowMtlsFallback bool + exceptionOnMissing bool +} + +//nolint:gocritic // Constructor. +func (this PreAuth) newPreAuth() *PreAuth { + this.principalHeader = "X-Remote-User" + this.rolesDelimiter = "," + this.allowMtlsFallback = true + return &this +} + +// PrincipalHeader returns the HTTP request header name used to extract the pre-authenticated principal. +func (this *PreAuth) PrincipalHeader() string { + return this.principalHeader +} + +// CredentialsHeader returns the HTTP request header name used to extract credentials. +func (this *PreAuth) CredentialsHeader() string { + return this.credentialsHeader +} + +// RolesHeader returns the HTTP request header name used to extract user roles/authorities. +func (this *PreAuth) RolesHeader() string { + return this.rolesHeader +} + +// RolesDelimiter returns the delimiter used to split the roles header value. +func (this *PreAuth) RolesDelimiter() string { + return this.rolesDelimiter +} + +// AllowMtlsFallback returns whether to fallback to direct mTLS client certificates if no header is found. +func (this *PreAuth) AllowMtlsFallback() bool { + return this.allowMtlsFallback +} + +// TrustedProxies returns the allow-listed proxy identifiers, if any. +func (this *PreAuth) TrustedProxies() []string { + return this.trustedProxies +} + +// IsTrustedProxy checks whether the given certificate matches the allow-listed trusted proxies. +func (this *PreAuth) IsTrustedProxy(cert *x509.Certificate) bool { + if len(this.trustedProxies) == 0 || cert == nil { + return false + } + for _, proxy := range this.trustedProxies { + if proxy == "" { + continue + } + if proxy == cert.Subject.CommonName || proxy == cert.Subject.String() { + return true + } + } + return false +} + +// ExceptionOnMissing returns whether missing pre-authenticated credentials immediately triggers 403 Forbidden. +func (this *PreAuth) ExceptionOnMissing() bool { + return this.exceptionOnMissing +} + +// PreAuthConfigurer lets you configure Pre-Authentication in an [HttpSecurityBuilder]. +type PreAuthConfigurer struct { + next *HttpSecurityBuilder +} + +func (this PreAuthConfigurer) newConfigurer(httpSecurityBuilder *HttpSecurityBuilder) *PreAuthConfigurer { + this.next = httpSecurityBuilder + this.next.httpSecurity.preAuth = PreAuth{}.newPreAuth() + return &this +} + +// PrincipalHeader sets the HTTP request header name used to extract the pre-authenticated principal. +// +// Defaults to "X-Remote-User". +func (this *PreAuthConfigurer) PrincipalHeader(header string) *PreAuthConfigurer { + this.next.httpSecurity.preAuth.principalHeader = header + return this +} + +// CredentialsHeader sets the HTTP request header name used to extract credentials. +// +// Defaults to empty (no credentials header). +func (this *PreAuthConfigurer) CredentialsHeader(header string) *PreAuthConfigurer { + this.next.httpSecurity.preAuth.credentialsHeader = header + return this +} + +// RolesHeader sets the HTTP request header name used to extract user roles/authorities. +// +// Defaults to empty (no roles header). +func (this *PreAuthConfigurer) RolesHeader(header string) *PreAuthConfigurer { + this.next.httpSecurity.preAuth.rolesHeader = header + return this +} + +// RolesDelimiter sets the delimiter used to split the roles header value. +// +// Defaults to ",". +func (this *PreAuthConfigurer) RolesDelimiter(delim string) *PreAuthConfigurer { + this.next.httpSecurity.preAuth.rolesDelimiter = delim + return this +} + +// AllowMtlsFallback configures whether to fallback to direct mTLS client certificates +// if no principal is found in request headers. +// +// Defaults to true. +func (this *PreAuthConfigurer) AllowMtlsFallback(allow bool) *PreAuthConfigurer { + this.next.httpSecurity.preAuth.allowMtlsFallback = allow + return this +} + +// TrustedProxies configures allow-listed proxy identifiers (e.g. CommonName or Subject) +// from which pre-authenticated request headers are accepted. +func (this *PreAuthConfigurer) TrustedProxies(proxies ...string) *PreAuthConfigurer { + for _, proxy := range proxies { + if proxy != "" { + this.next.httpSecurity.preAuth.trustedProxies = append(this.next.httpSecurity.preAuth.trustedProxies, proxy) + } + } + return this +} + +// ExceptionOnMissing configures whether to immediately reject the request with 403 Forbidden +// if the pre-authenticated principal header is missing, rather than letting downstream filters evaluate. +// +// Defaults to false. +func (this *PreAuthConfigurer) ExceptionOnMissing(exception bool) *PreAuthConfigurer { + this.next.httpSecurity.preAuth.exceptionOnMissing = exception + return this +} + +// And returns to [HttpSecurityBuilder]. +func (this *PreAuthConfigurer) And() *HttpSecurityBuilder { + return this.next +} diff --git a/pkg/config/preauth_test.go b/pkg/config/preauth_test.go new file mode 100644 index 0000000..60f8555 --- /dev/null +++ b/pkg/config/preauth_test.go @@ -0,0 +1,104 @@ +package config_test + +import ( + "crypto/x509" + "crypto/x509/pkix" + + . "github.com/BeamFoundry/rays-web-security/pkg/config" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PreAuthConfigurer", func() { + It("configures pre-authentication with defaults", func() { + security := (&HttpSecurityBuilder{}). + PreAuth(). + And(). + Build() + + Expect(security.PreAuth()).NotTo(BeNil()) + Expect(security.PreAuth().PrincipalHeader()).To(Equal("X-Remote-User")) + Expect(security.PreAuth().CredentialsHeader()).To(BeEmpty()) + Expect(security.PreAuth().RolesHeader()).To(BeEmpty()) + Expect(security.PreAuth().RolesDelimiter()).To(Equal(",")) + Expect(security.PreAuth().AllowMtlsFallback()).To(BeTrue()) + Expect(security.PreAuth().ExceptionOnMissing()).To(BeFalse()) + }) + + It("configures custom pre-authentication headers and options", func() { + security := (&HttpSecurityBuilder{}). + PreAuth(). + PrincipalHeader("SM_USER"). + CredentialsHeader("X-Client-Cert"). + RolesHeader("SM_GROUPS"). + RolesDelimiter(";"). + AllowMtlsFallback(false). + ExceptionOnMissing(true). + And(). + SessionCreationPolicy().Stateless(). + Build() + + Expect(security.PreAuth()).NotTo(BeNil()) + Expect(security.PreAuth().PrincipalHeader()).To(Equal("SM_USER")) + Expect(security.PreAuth().CredentialsHeader()).To(Equal("X-Client-Cert")) + Expect(security.PreAuth().RolesHeader()).To(Equal("SM_GROUPS")) + Expect(security.PreAuth().RolesDelimiter()).To(Equal(";")) + Expect(security.PreAuth().AllowMtlsFallback()).To(BeFalse()) + Expect(security.PreAuth().ExceptionOnMissing()).To(BeTrue()) + Expect(security.SessionCreationPolicy()).To(Equal(SessionCreationPolicy{}.New().STATELESS)) + }) + + It("configures and matches trusted proxies", func() { + security := (&HttpSecurityBuilder{}). + PreAuth(). + TrustedProxies("proxy.internal", "CN=gateway"). + And(). + Build() + + preAuth := security.PreAuth() + Expect(preAuth.TrustedProxies()).To(Equal([]string{"proxy.internal", "CN=gateway"})) + + // Discards empty entries + securityWithEmpty := (&HttpSecurityBuilder{}). + PreAuth(). + TrustedProxies("", "proxy.internal", "", "CN=gateway", ""). + And(). + Build() + Expect(securityWithEmpty.PreAuth().TrustedProxies()).To(Equal([]string{"proxy.internal", "CN=gateway"})) + + // Matches CN + certCN := &x509.Certificate{ + Subject: pkix.Name{CommonName: "proxy.internal"}, + } + Expect(preAuth.IsTrustedProxy(certCN)).To(BeTrue()) + + // Matches Subject.String() + certSubj := &x509.Certificate{ + Subject: pkix.Name{CommonName: "gateway"}, + } + Expect(preAuth.IsTrustedProxy(certSubj)).To(BeTrue()) + + // Certificate with empty subject cannot be trusted + emptyCert := &x509.Certificate{} + Expect(preAuth.IsTrustedProxy(emptyCert)).To(BeFalse()) + + // IsTrustedProxy skips empty entries even if present + preAuth.SetTrustedProxiesForTest([]string{"", "proxy.internal"}) + Expect(preAuth.IsTrustedProxy(emptyCert)).To(BeFalse()) + Expect(preAuth.IsTrustedProxy(certCN)).To(BeTrue()) + + // Non-matching cert + untrusted := &x509.Certificate{ + Subject: pkix.Name{CommonName: "attacker.com"}, + } + Expect(preAuth.IsTrustedProxy(untrusted)).To(BeFalse()) + + // Nil cert + Expect(preAuth.IsTrustedProxy(nil)).To(BeFalse()) + + // Empty trusted proxies + emptyPreAuth := (&HttpSecurityBuilder{}).PreAuth().And().Build().PreAuth() + Expect(emptyPreAuth.IsTrustedProxy(certCN)).To(BeFalse()) + }) +}) diff --git a/pkg/core/interface.go b/pkg/core/interface.go index 931cddc..8af0939 100644 --- a/pkg/core/interface.go +++ b/pkg/core/interface.go @@ -92,6 +92,15 @@ type UserDetailsService interface { LoadUserByUsername(username string) UserDetails } +// AuthenticationUserDetailsService is an interface used to retrieve UserDetails using an [Authentication] token. +// +// This is typically used in pre-authentication scenarios where extra attributes or authorities +// are present in the token rather than only a username. +type AuthenticationUserDetailsService interface { + // LoadUserDetails() should return a [UserDetails] given an [Authentication] token or an error. + LoadUserDetails(token Authentication) (UserDetails, AuthenticationException) +} + // AuthenticationProvider will handle authentication for a specific [Authentication]. type AuthenticationProvider interface { // Authenticate() will return an authenticated [Authenication] or an AuthenticationException diff --git a/pkg/core/security.go b/pkg/core/security.go index ca258af..9cda3bd 100644 --- a/pkg/core/security.go +++ b/pkg/core/security.go @@ -1,6 +1,7 @@ package core import ( + "crypto/tls" "net/http" "net/url" ) @@ -100,6 +101,13 @@ type SecurityHandler interface { Next() error } +// TLSConnectionHandler is an optional interface a [SecurityHandler] may implement +// to provide direct access to TLS connection state (e.g. for client certificate inspection). +type TLSConnectionHandler interface { + // TLSConnectionState() returns the TLS connection state or nil if not using TLS. + TLSConnectionState() *tls.ConnectionState +} + // SecurityRouter is a base interface used by [SecurityAppHandler]. type SecurityRouter interface { // Group() returns a new SecurityRouter for the given path diff --git a/pkg/testing/mock.go b/pkg/testing/mock.go index d0eb3d8..58bba26 100644 --- a/pkg/testing/mock.go +++ b/pkg/testing/mock.go @@ -3,6 +3,7 @@ package testing import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -143,6 +144,8 @@ type MockSecurityHandler struct { var _ SecurityHandler = &MockSecurityHandler{} +var _ TLSConnectionHandler = &MockSecurityHandler{} + func NewMockSecurityHandler(method HttpMethod, u *url.URL) *MockSecurityHandler { // httptest requires a string target, so we use u.String() // We also inject a nil body; form values will be handled via req.Form directly in the mock @@ -258,6 +261,11 @@ func (m *MockSecurityHandler) SetURL(u *url.URL) { m.Req.URL = u } +// TLSConnectionState implements [core.TLSConnectionHandler]. +func (m *MockSecurityHandler) TLSConnectionState() *tls.ConnectionState { + return m.Req.TLS +} + func (m *MockSecurityHandler) BindBody(obj any) error { contentType := m.Req.Header.Get("Content-Type") diff --git a/pkg/testing/mock_test.go b/pkg/testing/mock_test.go index f1c005f..009bcc3 100644 --- a/pkg/testing/mock_test.go +++ b/pkg/testing/mock_test.go @@ -412,5 +412,18 @@ var _ = Describe("Mock Security Filter ", func() { Expect(ex).To(HaveOccurred()) }) }) + + Context("MockSecurityAppHandler and Handler capabilities", func() { + It("supports PostAuthorizationFilter and TLSConnectionState", func() { + app := NewMockSecurityAppHandler() + handler := NewMockSecurityHandler(HttpMethods.GET, ValErr(url.Parse("https://www.example.com/test"))) + + // Test PostAuthorizationFilter + app.PostAuthorizationFilter(handler) + + // Test TLSConnectionState + Expect(handler.TLSConnectionState()).NotTo(BeNil()) + }) + }) }) })