Programming

OAuth2 for Spring RESTful API

Spread the love


In this article, I’ll first give a refresher of essential OAuth2 notions, then help you set up a complete testing environment on your desktop, and lastly, take a deeper dive into configuring security for Spring RESTful APIs

Google, Facebook, GitHub, Office365, and many others use OAuth2. Why is it so popular and superior to plain old login/password in each application?

  • Single Sign-On: users authenticated once could access all your services seamlessly, just like switching between Gmail, Drive, and YouTube, for instance.
  • User credentials are managed in one single place, which reduces the attack surface and reduces the chances a user database is leaked.
  • Login, logout, and user management are developed only once, which saves time and money.
  • SaaS or on-premise solutions are already available with many more features than your team could reasonably implement: multi-factor authentication, identity federation for most common providers (Google, Facebook, GitHub, etc.), connectors to LDAP, OIDC compliance, etc.

Keycloak of course does all that, but type “OIDC SaaS” in your favorite search engine and check how many results pop up.

Just Enough OAuth2 Background

OAuth2 defines 4 actors. It is essential to have a clear vision of who is who when you write your Spring configuration:

  • Resource Owner: Think of it as an end-user. Most frequently this is a physical person but can be a web service authenticated with client credentials (see below). In Spring security, it is represented by the Authentication in the security-context.
  • Resource Server: An API (most frequently REST) responsible for serving resources. In spring, this are applications with @RestController (or @Controller with @ResponseBody).
  • Client: A piece of software that needs to access resources on one or more resource servers; it does it in its own name (with client credentials) or on behalf of an end-user for whom it has an access token.
  • Authorization Server: The server issuing and certifying identities and identity delegation (what an end-user allowed a client to do on his behalf)

OAuth2 Flows

There are quite a few, but 2 are of interest to us:

Authorization Code

This is probably the most useful one. It is used to authenticate end-users (physical persons).

Authorization code flow

In OpenID environments, the resource server fetches the authorization server configuration from a standard path, either at startup or just before the first request is processed.

  1. An unauthorized user is redirected from the client application to the authorization server, most frequently using a system browser or a web view.
  2. The authorization server handles authentication (with forms, cookies, biometry, or whatever it likes) and then redirects the user to the client with a code to be used once.
  3. The client contacts the authorization server to exchange the code for an access token (and optionally refresh and ID tokens).
  4. The client sends requests to the resource server with the access token in the Authorization header.
  5. Two cases for token validation and details retrieval, depending on resource server configuration, are as follows:
  • A JWT decoder reads the token and validates it with the authorization server public key (downloaded once at startup).
  • A request is sent to the authorization server introspection endpoint (for each and every request).

Client Credentials

The client sends the client id and secret to the authorization server which returns an access token to be used to authenticate the client itself (no user context). This must be limited to clients running on a server you trust (capable of keeping a secret actually “secret”) and excludes all services running in a browser or a mobile app (code can be reverse engineered to read secrets).

Tokens

A token represents a resource owner’s identity and what the client can do on his behalf, pretty much like a paper proxy you could give to someone else to vote for you. It contains the following attributes at a minimum:

  • Issuer: The authorization server which emitted the token (police officer or alike who certified identities of people who gave and received proxy)
  • Subject: A resource owner unique identifier (person who grants the proxy)
  • Scope: What this token can be used for (did the resource owner grant a proxy for voting, managing a bank account, get a parcel at the post office, etc.)
  • Expiry: Until when can this token be used

JWT

A JWT is a JSON Web Token. It is used primarily as an access or ID token with OAuth2. JWTs can be validated on their own by a JWT decoder, which needs no more than an authorization server public signing key.

Opaque Tokens

Opaque tokens can be used (any format, including JWT), but it requires introspection: clients and resource servers have to send a request to the authorization server to ensure the token is valid and get token “attributes” (equivalent to JWT “claims”). This process can have a serious performance impact compared to JWT decoding.

Access Token

This is a token to be sent by the client as a Bearer Authorization header in its requests to the resource server. Access tokens content should remain a concern of authorization and resource servers only (clients should not try to read access tokens).

Refresh Token

This token is to be sent by the client to the authorization server to get a new access token when it expires (or preferably just before).

ID Token 

The ID token is part of the OpenID extension to OAuth2 and is a token to be used by the client to get user info.

Scope

Scope defines what the user allowed a client to do in his name (not what the user is allowed to do in the system). You might think of it as a mask applied to resource owner resources before a client accesses them.

OpenID

This is a standard on top of OAuth2 with, among other things, standard claims.

Authorization Server Requirements

To proceed with the following tutorial, we’ll need an OpenID authorization server with a few declared clients and resource owners. For the sake of simplicity, we’ll use a standalone Keycloak distribution powered by Quarkus.

Server Configuration

If you don’t have an SSL certificate for your host already, generate one (read it carefully until the end).

Once you have decompressed the Keycloak archive, edit conf/keycloak.conf file:

http-port=8442
https-key-store-file=/path/to/self_signed.jks
https-key-store-password=change-me
https-port=8443

You’re all set: just start with bin/kc.bat start-dev or bin/kc.sh start-dev and connect to https://localhost:8443.

Clients

We need two different clients:

  • spring-addons-confidential will be created with “Client authentication” and “Service accounts roles” enabled. This client will be used by applications we trust, running on servers we trust, to contact the authorization server using client-credentials flow.

spring-addons-confidential will be created with "Client authentication" and "Service accounts roles" enabled

  • spring-addons-public will be created without client authentication and with “Standard flow.” This client will be used by web/mobile apps and REST clients like Postman to authenticate users.

spring-addons-public will be created without client authentication and with Standard flow

You’ll have to define a few valid redirect URLs for the public client (sample below for an Angular application running on dev-server):

Define a few valid redirect URLs for the public client

Don’t forget to save after you added the URLs.

Roles

We’ll create a single “Realm role” named NICE.

Alternatively, you can define roles at the client level. If you’re doing so, also enable the “client roles” mapper from Client details-> Client scopes -> spring-addons-[public|confidential]-dedicated -> Add mapper -> From predefined mappers.

Users

Let’s create two users:

  • brice with NICE role (should be allowed to get greetings)
  • igor with no role (should be forbidden to get greetings)

Spring Boot Resource Server Security Configuration

We’ll see that with the help of Spring Boot, we can build a secured resource server in minutes, including security rules unit testing.

Requirements

  • Spring Boot 2.7 and later: without the deprecated WebSecurityConfigurerAdapter
  • User authorities should be mapped from realm_access.roles, resource-access.spring-addons-confidential.roles and resource-access.spring-addons-public.roles claims which are where Keycloak puts users’ roles
  • A controller with a GET endpoint returns a greeting only if a user is granted with NICE authority (and 401 if authentication is missing/invalid or a 403 if NICE role is missing)
  • CORS: required for “pure” resource servers; UI components being served from another socket, host, or domain; cross-domain access is necessary
  • “Stateless” session management: no servlet session; client state is managed with URIs and access token
  • CSRF protection (with cookie repository because of stateless session management)
  • OpenAPI spec (from spring-doc-openapi) as well as readiness and liveness probes should be accessible to anonymous 
  • All other routes are restricted to authenticated users (fine-grained security rules annotated on @Controllers methods with @PreAuthorize)
  • 401 unauthorized (instead of 302 redirect to login) when the request is issued to the protected resource with a missing or invalid authorization header
  • Force HTTPS usage if SSL is enabled

spring-boot-starter-oauth2-resource-server

Spring Boot provides a library to ease the resource server’s security configuration: spring-boot-starter-oauth2-resource-server. Let’s implement the above requirements with it.

Open Spring initializr and generate a project with the following dependencies:

  • Spring Web
  • OAuth2 Resource Server
  • Spring Boot Actuator
  • Lombok

Generate a project in spring initializr

Once downloaded and unpacked, add the following dependencies:

		<dependency>
			<groupId>org.springdoc</groupId>
			<artifactId>springdoc-openapi-security</artifactId>
			<version>1.6.9</version>
		</dependency>
		<dependency>
			<groupId>org.springdoc</groupId>
			<artifactId>springdoc-openapi-ui</artifactId>
			<version>1.6.9</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

Now, we can configure web security the Spring Boot 2.7+ way: providing a SecurityFilterChain bean instead of extending the deprecated WebSecurityConfigurerAdapter. This is quite some Java code, but we will see just after how to reduce it to almost nothing.

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
    @Bean
    public
            SecurityFilterChain
            filterChain(HttpSecurity http, Converter<Jwt, ? extends AbstractAuthenticationToken> authenticationConverter, ServerProperties serverProperties)
                    throws Exception {

        // Enable OAuth2 with custom authorities mapping
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);

        // Enable anonymous
        http.anonymous();

        // Enable and configure CORS
        http.cors().configurationSource(corsConfigurationSource());

        // State-less session (state in access-token only)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Enable CSRF with cookie repo because of state-less session-management
        http.csrf().csrfTokenRepository(new CookieCsrfTokenRepository());

        // Return 401 (unauthorized) instead of 302 (redirect to login) when authorization is missing or invalid
        http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
            response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        });

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        } else {
            http.requiresChannel().anyRequest().requiresInsecure();
        }

        // Route security: authenticated to all routes but actuator and Swagger-UI
        // @formatter:off
        http.authorizeRequests()
            .antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated();
        // @formatter:on

        return http.build();
    }

    public interface Jw2tAuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
    }

    public interface Jwt2AuthenticationConverter extends Converter<Jwt, JwtAuthenticationToken> {
    }

    @Bean
    public Jwt2AuthenticationConverter authenticationConverter(Jw2tAuthoritiesConverter authoritiesConverter) {
        return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
    }

    @SuppressWarnings("unchecked")
    @Bean
    public Jw2tAuthoritiesConverter authoritiesConverter() {
        // This is a converter for roles as embedded in the JWT by a Keycloak server
        // Roles are taken from both realm_access.roles & resource_access.{client}.roles
        return jwt -> {
            final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
            final var realmRoles = (Collection<String>) realmAccess.getOrDefault("roles", List.of());

            final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
            // We assume here you have "spring-addons-confidential" and "spring-addons-public" clients configured with "client roles" mapper in Keycloak
            final var confidentialClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-confidential", Map.of());
            final var confidentialClientRoles = (Collection<String>) confidentialClientAccess.getOrDefault("roles", List.of());
            final var publicClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-public", Map.of());
            final var publicClientRoles = (Collection<String>) publicClientAccess.getOrDefault("roles", List.of());

            return Stream.concat(realmRoles.stream(), Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
                    .map(SimpleGrantedAuthority::new).toList();
        };
    }

    private CorsConfigurationSource corsConfigurationSource() {
        // Very permissive CORS config...
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));

        // Limited to API routes (neither actuator nor Swagger-UI)
        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/greet/**", configuration);

        return source;
    }
}

Of course, we also need a secured REST @Controller:

@RestController
@RequestMapping("/greet")
@PreAuthorize("isAuthenticated()")
public class GreetingController {

    @GetMapping()
    @PreAuthorize("hasAuthority('NICE')")
    public String getGreeting(JwtAuthenticationToken auth) {
        return "Hi %s! You are granted with: %s.".formatted(
                auth.getToken().getClaimAsString(StandardClaimNames.PREFERRED_USERNAME),
                auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(", ", "[", "]")));
    }
}

Lastly, we need a few entries in application.properties:

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://localhost:8443/realms/master

management.endpoint.health.probes.enabled=true
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
management.endpoints.web.exposure.include=*
spring.lifecycle.timeout-per-shutdown-phase=30s

logging.level.org.springframework.security.web.csrf=DEBUG

The application should now run on port 8080 and expose a secured endpoint accessible to brice only.

You can use Postman to get an access token from Keycloak and then send a test request:

  • Create a new collection
  • In the Authorization tab, select OAuth2 and click “Edit token configuration” to enter the following values:
    • Grant type: authorization code (with PKCSE)
    • Callback URL: https://localhost:4200 (or what you set when configuring spring-addons-public client in Keycloak)
    • Auth URL: https://localhost:8443/realms/master/protocol/openid-connect/auth
    • Access token URL: https://localhost:8443/realms/master/protocol/openid-connect/token
    • Client ID: spring-addons-public
    • Scope: OpenID spring-addons-public-dedicated profile email offline_access
  • After you authenticate as brice, click “use token” and add a new GET request to https://localhost:8080/greet. You should get a greeting (but not if you authenticate as igor).

Configuration Cut Down

The list of features we implemented in the web-security configuration is something very generic we would need in most resource servers. Instead of duplicating this code, we could use one of the alternate spring-addons starters declined in 4 variants for combinations of Web MVC vs WebFlux and JWT decoder vs introspection.

 As we wrote a servlet app with a JWT decoder, we will replace spring-boot-starter-oauth2-resource-server with spring-addons-webmvc-jwt-resource-server:

		<dependency>
			<groupId>com.c4-soft.springaddons</groupId>
			<artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
			<version>5.1.3</version>
		</dependency>

We can now remove almost all of the web security configuration:

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
}

Just provide a few properties:

# shoud be set to where your authorization-server is
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/master

# shoud be configured with a list of private-claims this authorization-server puts user roles into
# below is default Keycloak conf for a `spring-addons` client with client roles mapper enabled
com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,resource-access.spring-addons-confidential.roles,resource_access.spring-addons-public.roles

com.c4-soft.springaddons.security.permit-all=/actuator/health/readiness,/actuator/health/liveness,/v3/api-docs/**

# use IDE auto-completion or see SpringAddonsSecurityProperties javadoc for complete configuration properties list

management.endpoint.health.probes.enabled=true
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
management.endpoints.web.exposure.include=*
spring.lifecycle.timeout-per-shutdown-phase=30s

Bootiful, isn’t it?

Nothing magic here: it is just a Spring Boot module with a few @ConditionalOnMissingBean definitions which provide sensible defaults you can easily override.

As spring-addons sets security-context with a OAthentication<OpenidClaimSet> instance (instead of JwtAuthenticationToken) there is a slight modification to make to our controller:

@RestController
@RequestMapping("/greet")
@PreAuthorize("isAuthenticated()")
public class GreetingController {

    @GetMapping()
    @PreAuthorize("hasAuthority('NICE')")
    public String getGreeting(OAuthentication<OpenidClaimSet> auth) {
        return "Hi %s! You are granted with: %s.".formatted(
                auth.getClaims().getPreferredUsername(),
                auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(", ", "[", "]")));
    }
}

Unit Testing

We saw how to add Role Based Access Control to our Spring methods with expressions like @PreAuthorize("hasAuthority('NICE')"), which make assertions based on the identity and roles contained in JWT access tokens (or exposed on authorization server introspection endpoint).

Let’s now see how to unit-test those security rules. 

spring-security-test provides with MockMvc post-processors and WebTestClient mutators to populate test security-context with JwtAuthenticationToken or BearerTokenAuthentication which are default Authentication for apps with respectively JWT decoder or token introspection. 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

@WebMvcTest(controllers = GreetingController.class, properties = "server.ssl.enabled=false")
@Import({ WebSecurityConfig.class })
class GreetingControllerTest {

    @MockBean
    AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver;

    @Autowired
    MockMvc mockMvc;

    @Test
    void whenGrantedNiceRoleThenOk() throws Exception {
        mockMvc.perform(get("/greet").with(jwt().jwt(jwt -> {
            jwt.claim("preferred_username", "Tonton Pirate");
        }).authorities(List.of(new SimpleGrantedAuthority("NICE"), new SimpleGrantedAuthority("AUTHOR"))))).andExpect(status().isOk())
                .andExpect(content().string("Hi Tonton Pirate! You are granted with: [NICE, AUTHOR]."));
    }

    @Test
    void whenNotGrantedNiceRoleThenForbidden() throws Exception {
        mockMvc.perform(get("/greet").with(jwt().jwt(jwt -> {
            jwt.claim("preferred_username", "Tonton Pirate");
        }).authorities(List.of(new SimpleGrantedAuthority("AUTHOR"))))).andExpect(status().isForbidden());
    }

    @Test
    void whenAnonymousThenUnauthorized() throws Exception {
        mockMvc.perform(get("/greet")).andExpect(status().isUnauthorized());
    }
}

However, this comes with limitations:

  • Only @Controller security can be tested (other @Component unit-test do not run in the context of MockMvc or WebTestClient request).
  • It puts quite some mess in the test request definition.

As an alternative, we can add a dependency on spring-addons-webmvc-jwt-test:

		<dependency>
			<groupId>com.c4-soft.springaddons</groupId>
			<artifactId>spring-addons-webmvc-jwt-test</artifactId>
			<version>5.1.3</version>
			<scope>test</scope>
		</dependency>

It contains test annotations similar to @WithMockUser, injecting other types of Authentication:

  • @WithMockJwtAuth builds a JwtAuthenticationToken (default for JWT decoders).
  • @WithMockBearerTokenAuthentication builds a BearerTokenAuthentication (default for token introspection).
  • @OpenId builds an OAuthentication<OpenidClaimSet> (default for spring-addons starters).

The test above becomes:

@WebMvcTest(controllers = GreetingController.class)
@AutoConfigureAddonsSecurityWebmvcJwt
@Import(WebSecurityConfig.class)
class GreetingControllerTest {

    @Autowired
    MockMvcSupport mockMvc;

    @Test
    @OpenId(authorities = { "NICE", "AUTHOR" }, claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
    void whenGrantedWithNiceRoleThenCanGreet() throws Exception {
        mockMvc.get("/greet").andExpect(status().isOk()).andExpect(content().string("Hi Tonton Pirate! You are granted with: [NICE, AUTHOR]."));
    }

    @Test
    @OpenId(authorities = { "AUTHOR" }, claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
    void whenNotGrantedWithNiceRoleThenForbidden() throws Exception {
        mockMvc.get("/greet").andExpect(status().isForbidden());
    }

    @Test
    void whenAnonymousThenUnauthorized() throws Exception {
        mockMvc.get("/greet").andExpect(status().isUnauthorized());
    }
}

If you have not applied the “configuration cut-down” above:

  • Replace spring-addons-webmvc-jwt-test dependency with spring-addons-oauth2-test.
  • Replace @OpenId with @WithMockJwtAuth .
  • Replace MockMvcSupport with MockMvc.
  • Replace get("/greet") with perform(get("/greet")).
  • Remove @AutoConfigureSecurityAddons
  • Add @MockBean JwtDecoder jwtDecoder;
  • Be sure to import all of your security conf.
  • Double-check that you add .secure(true) and .csrf() to each and every request builder if it is activated in your security conf (MockMvcSupport does it automatically).

So yes, Spring addons can ease your life for unit testing too. 

To Go Further

I omitted the client’s configuration. You should pick a lib from certified implementations. I’m personally more experienced with Angular and have a preference for angular-auth-oidc-client.

To learn how to override default @ConditionalOnMissingBean from spring-addons, you might refer to this advanced tutorial which covers:

  • Parsing of private claims served by your authorization server
  • Extending Authentication implementation to implement security rules based on more than just roles
  • Enriching Spring security DSL (@PreAuthorize and @PostFilter expressions)

If you’re interested in token introspection, you can refer to this other tutorial, “How to configure a Spring REST API with token introspection.”



Source link

Related Articles

Leave a Reply

Your email address will not be published.

Back to top button