ch4mpy / spring-addons

Ease spring OAuth2 resource-servers configuration and testing
Apache License 2.0
521 stars 84 forks source link

Remind the user to omit the protocol prefix for POST, DELETE and PUT requests in Angular #170

Closed eayin2 closed 7 months ago

eayin2 commented 7 months ago

Add a comment to the BFF frontend as a reminder that the CSRF header is added only in Angular if the URL starts with //.

Example: gatewayUri = '//localhost:8080'

For idempotent GET requests it does not matter, but for POST, DELETE and PUT requests this would cause an exception, when the BFF tries to resolve the CSRF token, as the CSRF header would be missing (CSRF-TOKEN would only be inside the cookie header).

See https://github.com/angular/angular/issues/20511

eayin2 commented 7 months ago

I also ran SSL locally and did not notice the missing CSRF header at first because the BFF handles the GET requests without CSRF token resolution. However, for DELETE and POST requests, I encountered missing CSRF token exceptions with https:// prefixed API URLs using Angular's HttpClient. In the source code you linked on line L82, https:// is also one of the conditions.

If needed, I can write an example in the bff tutorial for a POST endpoint.

ch4mpy commented 7 months ago

Actually, there already is a request requiring X-CSRF-TOKEN header in UserService for logout: this.http.post('/logout', null, { observe: 'response' }). It has no authority (no scheme, userinfo, (sub)domain or port).

As we configured SAMEORIGIN for Angular and API (we are using the gateway to serve both REST resources and Angular assets), we should be using only path (no scheme nor authority). This what I do for instance in this complete project.

Maybe should we just remove any reference to gatewayUri from this Angular project? I just tested and it works.

eayin2 commented 7 months ago

Yes, this works if the user accesses the frontend through the BFF domain, as the BFF routes to the UI and therefore relative requests can be issued.

Maybe it is better that the user accesses the frontend through the domain where the frontend is served (webserver or CDN) to avoid unnecessary load on the BFF? I think in a typical BFF architecture the BFF is just between the frontend and the resources.

I do not know how to configure spring-addons to access authorized resources from the frontend domain (e.g. https://localhost:4200). When I tried, I got a 401 unauthorized error. CORS works fine by adding the frontend (https://localhost:4200) to the BFF and resource server CORS allowed-origin patterns, but there seems to be an authorization problem.

I see this error on the resource server:

2024-01-10T21:47:20.663+01:00 DEBUG 22992 --- [nio-7084-exec-9] horizationManagerBeforeMethodInterceptor : Authorizing method invocation ReflectiveMethodInvocation: public org.springframework.data.domain.Page com.c4_soft.dzone_oauth2_spring.official_greeting_api.Employee.EmployeeController.getAllEmployees(int,int,java.lang.String,java.lang.String); target is of class [com.c4_soft.dzone_oauth2_spring.official_greeting_api.Employee.EmployeeController]
2024-01-10T21:47:20.663+01:00 DEBUG 22992 --- [nio-7084-exec-9] horizationManagerBeforeMethodInterceptor : Failed to authorize ReflectiveMethodInvocation: public org.springframework.data.domain.Page com.c4_soft.dzone_oauth2_spring.official_greeting_api.Employee.EmployeeController.getAllEmployees(int,int,java.lang.String,java.lang.String); target is of class [com.c4_soft.dzone_oauth2_spring.official_greeting_api.Employee.EmployeeController] with authorization manager org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@70b5db82 and decision ExpressionAuthorizationDecision [granted=false, expressionAttribute=hasAuthority('NICE')]
2024-01-10T21:47:20.664+01:00 DEBUG 22992 --- [nio-7084-exec-9] o.s.security.web.FilterChainProxy        : Securing GET /error?pageNo=0&pageSize=5
2024-01-10T21:47:20.664+01:00 DEBUG 22992 --- [nio-7084-exec-9] o.s.s.w.a.c.ChannelProcessingFilter      : Request: filter invocation [GET /error?pageNo=0&pageSize=5]; ConfigAttributes: [REQUIRES_SECURE_CHANNEL]
2024-01-10T21:47:20.664+01:00 DEBUG 22992 --- [nio-7084-exec-9] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext

and on the BFF I see these debug messages:

2024-01-10T22:02:03.243+01:00 DEBUG 3060 --- [     parallel-5] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.security.config.web.server.ServerHttpSecurity$OAuth2LoginSpec$OidcSessionRegistryWebFilter$OidcSessionRegistryServerWebExchange$OidcSessionRegistryWebSession@36c6db89'
2024-01-10T22:02:03.243+01:00 DEBUG 3060 --- [     parallel-5] s.w.s.a.AnonymousAuthenticationWebFilter : Populated SecurityContext with anonymous token: 'AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_ANONYMOUS]]'

I am curious if it is possible to configure spring-addons so that authorized requests can be made from the frontend's domain.

Otherwise, I think removing the gatewayUri from the Angular project entirely is fine if the site is only accessed through the BFF domain.

ch4mpy commented 7 months ago

If you don't serve the UI through the gateway, there shouldn't be more to do than configuring CORS.

In the case of your error, it seems that the user is not authorized on the BFF. Maybe he never was or the session cookie could be lost or whatever. Can you give me access to your project?

Using something to achieve same origin is convenient for CORS configuration (removes the need for it), but it also can be required in some condition like working with iframes. Lately, I configured same origin for UI, BFF and even the authorization server to display login forms in an iframe (displayed as a modal, which provides with great integration in the app).

As a side note, you don't have to use the BFF as reverse proxy. You can put anything you like in front of it for that: another (stateless) spring-cloud-gateway instance, a K8s ingress, ...

Back to your PR: I suggest that:

ch4mpy commented 7 months ago

Thank you @eayin2 for taking time to investigate and submit this PR

eayin2 commented 7 months ago

Thank you for your guidance and extraordinary work on this project! Tomorrow I will take a look at the authorization issue, in the meantime I have shared access to the repositories with you

ch4mpy commented 7 months ago

@eayin2 I don't have a valid email for you, so putting this here...

I gave an eye to your project and after a little cleanup, it seems to work as expected.

I pushed a new "ch4mpy" branch to each of your repos.

Please note I used an additional "reverse-proxy" service. This is a very simple Gateway with routing to each of the other services (which I configured to run on the port you actually use for the BFF so that the URLs you use currently don't change).

As Keycloak is accessed with a path prefix, I have some extra conf for it:

# using the URI through the reverse-proxy
hostname-url=https://phw.devfra:8082/auth
hostname-admin-url=https://phw.devfra:8082/auth
# this sets a "baseHref" for Keycloak
http-relative-path=/auth

The spring-cloud-gateway instance used as reverse proxy has the following configuration:

# properties common to all services
# sharing names makes it easy to override with environment variables, in K8s configmap, etc.
scheme: http
hostname: phw.devfra
reverse-proxy-port: 8082
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
bff-port: 8083
bff-uri: ${scheme}://${hostname}:${bff-port}
resource-server-port: 7084
resource-server-uri: ${scheme}://${hostname}:${resource-server-port}
ui-port: 4200
ui-uri: ${scheme}://${hostname}:${ui-port}
issuer: ${reverse-proxy-uri}/auth/realms/master
user-name-attribute: preferred_username

server:
  port: ${reverse-proxy-port}
  ssl:
    enabled: false

spring:
  cloud:
    gateway:
      routes:
      - id: home
        predicates:
        - Path=/
        uri: ${reverse-proxy-uri}
        filters:
        - RedirectTo=301,${reverse-proxy-uri}/ui/
      - id: ui
        predicates:
        - Path=/ui/**
        uri: ${ui-uri}
      - id: bff
        predicates:
        - Path=/bff/**,/login/**,/oauth2/**,/logout,/login-options
        uri: ${bff-uri}
      - id: authorization-server
        predicates:
        - Path=/auth/**
        uri: https://${hostname}:8443/auth/realms/master

---
# you should not have passwords in your properties
# set it as environment variable lik SERVER_SSL_KEY_STORE_PASSWORD
# or in your IDE launch config
# also, certificate files should be added to .gitignore (and removed from your Github repo)
scheme: https
server:
  ssl:
    enabled: true

spring:
  config:
    activate:
      on-profile: ssl

with just this pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.1</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.c4soft.eayin2</groupId>
    <artifactId>reverse-proxy</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>reverse-proxy</name>
    <description>Reverse proxy to have SAMEORIGIN for all requests</description>
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2023.0.0</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

As a side note, things would probably be easier to sync if you were using a single repo for all your projects, with a parent pom for your different Spring projects (and also, I could have added this dev reverse-proxy to my branch).

eayin2 commented 7 months ago

Thanks for your message! Using a reverse proxy worked fine. This also facilitates serving the frontend through CDNs, the only difference being that you would have to use the CDN's reverse proxy configurations to forward API requests from relative paths to the BFF domain. I have included a sample nginx.conf reverse proxy configuration.

ch4mpy commented 7 months ago

Your nginx.conf and the spring-cloud-gateway conf I put above do the same: provide with SAMEORIGIN for all services. I'm sure we could find more technical solutions to do it (and probably more valuable when working with this or that managed cloud).

A warning with the conf above: including the authorization server in the services hidden behind the reverse proxy is something very convenient when working with iframes, but it might compromise SSO between applications served from different domains but using the same authorization server.

As conclusion, only frontend and BFF are required to share the SAMEORIGIN for the solution in the tutorial to work. For the authorization server, this depends on the use-case.

Thank you again for the time you spent investigating, reporting and fixing. Thanks also for sharing your projects to ease my investigations (I don't need access anymore).