- Spring Boot Secure API by OpenId Connect using Spring Security
This is an exemple of Rest API were some endpoints are secured by an OpenId Connect This application contains two endpoints
/
is a public endpoint/api/private
is a private endpoint- this endpoint is callable using
GET
verb : only authenticated user withreader
orwriter
role can call.POST
verb : only authenticated user withwriter
role can call.
- this endpoint is callable using
@GetMapping("/")
public String publicEndpoint() {
return "Hello Public Ok";
}
@RolesAllowed({ "ROLE_reader", "ROLE_writer" })
@GetMapping("/api/private")
public Authentication privateEndpoint(Authentication authentication) {
return authentication;
}
@RolesAllowed({ "ROLE_writer" })
@PostMapping("/api/private")
public String privateEndpointWrite() {
return "done";
}
For this excercice we are using keycloak
as OpenId provider.
This exemple inclu preconfigured keycloak
instance (h2 db is provided into src/docker/keycloak.mv.db
). This instance contains
- a
organisation
realm - a
client1
client inside theorganisation
realm - two roles
reader
andwriter
- three users with the same password
password
:test
having thewriter
roletest2
having thereader
roletest3
without role
To use this app the following prerequisite are needed :
- docker
- docker-compose
- openjdk
$ docker-compose -f src/docker/docker-compose.yml build
Run the following command to launch the application and the keycloak instance.
The app
container will wait until keycloak start and will launch the java application.
$ docker-compose -f src/docker/docker-compose.yml up -d --force-recreate
$ docker-compose -f src/docker/docker-compose.yml logs -f
- You should be able to access The organisation realm here
- You should be able to access The client1 client here
- You should be able to access The keycloak users page here. Click the
View all users
button to see users. - You should be able to access The role mappings page here of the user having the login
test
. Click theClient Roles
dropdown and tapeclient1
. You should see that the user have onlywriter
intoAssigned Roles
select.
Run the following command to have a shell inside the app container
$ docker-compose -f src/docker/docker-compose.yml exec app bash
root@fff93b8266a1:/sources#
Always inside the
app
container, run this command to test that the public endpoint is up and running.
$ curl "localhost:8081/" -s
Hello Public Ok
Always inside the
app
container, using the following command generate a JWT token of the usertest
$ curl -s -d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'username=test' \
-d 'password=password' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r
You can decode the generated token using jwt.io web site
Always inside the
app
container, run this command to test that the private endpoint is secured. Without bearer user is redirected to the keycloak login page.
$ curl "localhost:8081/api/private" -vL
< HTTP/1.1 302
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Location: http://localhost:8081/oauth2/authorization/organisation
< Content-Length: 0
< Date: Sat, 18 Apr 2020 15:37:56 GMT
<
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'http://localhost:8081/oauth2/authorization/organisation'
* Found bundle for host localhost: 0x56357c93b980 [can pipeline]
* Could pipeline, but not asked to!
* Re-using existing connection! (#0) with host localhost
* Connected to localhost (127.0.0.1) port 8081 (#0)
* Expire in 0 ms for 6 (transfer 0x56357c940f50)
> GET /oauth2/authorization/organisation HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
>
< HTTP/1.1 302
< Set-Cookie: JSESSIONID=FE73A1CFE7BBC8D92843240E2C14D54A; Path=/; HttpOnly
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Location: http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/auth?response_type=code&client_id=client1&scope=openid%20profile%20email&state=Vursu6cdVMD0_xWBrOYbo-XnWc4Jfkf669IuCZB9jVw%3D&redirect_uri=http://localhost:8081/login/oauth2/code/organisation&nonce=AfbDVJTZ4TXsdbQilshIx4IzlhW5IwJPnQkr6je1zFI
< Content-Length: 0
< Date: Sat, 18 Apr 2020 15:37:56 GMT
<
Always inside the
app
container, run this command to test that
- With
test
ortest2
bearer response is200
.- With
test3
bearer response is403
.
The command below is composed by two curl.
- one curl that generate a jwt token by calling keycloak
- the second curl use the generated token as a Bearer
$ export bearer_jwt=$(curl -s \
-d 'username=test' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' \
-H "Authorization: Bearer ${bearer_jwt}"
$ export bearer_jwt=$(curl -s \
-d 'username=test2' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' \
-H "Authorization: Bearer ${bearer_jwt}"
$ export bearer_jwt=$(curl -s \
-d 'username=test3' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' \
-H "Authorization: Bearer ${bearer_jwt}"
Always inside the
app
container, run this command to test that
- With
test
bearer response is200
.- With
test2
ortest3
bearer response is403
.
The curl here is the same as previous, the only difference is -XPOST
which means that we using the verb POST.
$ export bearer_jwt=$(curl -s \
-d 'username=test' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' -XPOST \
-H "Authorization: Bearer ${bearer_jwt}"
$ export bearer_jwt=$(curl -s \
-d 'username=test2' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' -XPOST \
-H "Authorization: Bearer ${bearer_jwt}"
$ export bearer_jwt=$(curl -s \
-d 'username=test3' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' -XPOST \
-H "Authorization: Bearer ${bearer_jwt}"
Spring Security provide the starter spring-boot-starter-oauth2-client
that activate protection of the application using Oauth and Openid Connect proovider.
it support Google / Facebook / Github or custom provider
- by default all endpoints are secured
- token is stored into HttpSession.
- authentification by Authorization header is not supported
- roles based access is not supported
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Adding this dependency will not have effect until the spring.security.oauth2.client.registration
configuration is added
spring:
security:
oauth2:
client:
registration:
organisation:
client-id: client1
# client-name: client1
client-secret: 7926b321-48ef-4ba9-9c57-ee9c98de7dd6
# client-authentication-method:
authorization-grant-type: authorization_code
# http://localhost:8081/login/oauth2/code/organisation
redirectUri: '{baseUrl}/login/oauth2/code/{registrationId}'
scope:
- openid
- profile
- email
provider:
organisation:
issuer-uri: http://keycloak:8080/auth/realms/organisation
user-name-attribute: preferred_username
Spring Security provide the spring-security-oauth2-resource-server
lib that implement a oauth2 resource server. The resource server is the OAuth 2.0 term for your API server. The resource server handles authenticated requests after the application has obtained an access token. This include :
- Verifying Access Tokens included into HTTP Authorization header
- Verifying Scope or Roles
- The following Error codes are implemented
- invalid_token (HTTP 401) – The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again
- insufficient_scope (HTTP 403) – The access token is valid but don't contains the right roles
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
After adding this dependency you need to define spring.security.oauth2.resourceserver.jwt.issuer-uri
or spring.security.oauth2.resourceserver.jwt.jwk-set-uri
needed to retrieve the JWK Set and verify the signature of the JWT.
Into this example we choose to set the issuer-uri
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://keycloak:8080/auth/realms/organisation
The default spring security WebSecurityConfigurerAdapter
request authentication for any endpoint.
protected void configure(HttpSecurity http) throws Exception {
...
http
.authorizeRequests()
.anyRequest().authenticated()
...
- To override this behavior, we need to provide a custom WebSecurityConfigurerAdapter class and using
@EnableWebSecurity
we activate this class. - we use the
EnableGlobalMethodSecurity
annotation to enable thejsr250Enabled
support, this is enable the support of@RolesAllowed
annotation used intoContoller
level. SessionCreationPolicy
is set toSTATELESS
to disable HttpSession usage- The spring security resource server don't map JWT roles to the spring security principal. So we can't use @Secured or @RolesAllowed to manage endpoints based on JWT roles. To fix that we have to implement a custom
jwtAuthenticationConverter
@EnableWebSecurity
@EnableGlobalMethodSecurity(
jsr250Enabled = true)
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// disable usage of HTTP session to store tokens
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// configure login with oauth2 client
.oauth2Login()
.and()
// activate oauth2 resource server that add authentification with 'Authorization: Bearer' header
.oauth2ResourceServer()
.jwt()
// add JWT converter to map roles into principal to be able to use into @Secured
.jwtAuthenticationConverter(getJwtAuthenticationConverter())
;
}
In this step we provide a custom implementation to AuthorizedClientRepository
.
We store Access and Refresh token into cookies.
We use an new AuthenticationFilter to attempt authentication using same cookies.
...
.oauth2Login()
// using custom authorized client repository
// that store tokens into cookies
.authorizedClientRepository(this.cookieAuthorizedClientRepository())
.and()
// added filter that attempt authentication using cookie stored by CookieAuthorizedClientRepository
.addFilterAfter(getCookieTokenAuthenticationFilter(http), BearerTokenAuthenticationFilter.class)
...
Always inside the app
container, run this command to test this feature
export bearer_jwt=$(curl -s \
-d 'username=test' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://keycloak:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' \
--cookie "OIDC_ACCESS_TOKEN=${bearer_jwt}"
- Spring Security OAuth2 Client
- Spring Security Resource Server
- Resource Server Definition
- Spring Method Security
To launch this application into our IDE you need to do the following steps
- Launch keycloak using
docker-compose -f src/docker/docker-compose-local.yml up -d
- Launch you application using the local spring profile. Here is an exemple using maven and spring-boot:run
./mvnw clean package spring-boot:run -Dspring-boot.run.profiles=local
- you and test using the following commands
export bearer_jwt=$(curl -s \
-d 'username=test' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://localhost:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' \
-H "Authorization: Bearer ${bearer_jwt}"
export bearer_jwt=$(curl -s \
-d 'username=test' \
-d 'password=password' \
-d 'client_id=client1' \
-d 'client_secret=7926b321-48ef-4ba9-9c57-ee9c98de7dd6' \
-d 'grant_type=password' \
'http://localhost:8080/auth/realms/organisation/protocol/openid-connect/token' \
| jq .access_token -r) \
\
&& curl -v 'localhost:8081/api/private' \
--cookie "OIDC_ACCESS_TOKEN=${bearer_jwt}"
- howto to manage refresh token
- redirect to the original url after login success