- 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/privateis a private endpoint- this endpoint is callable using
GETverb : only authenticated user withreaderorwriterrole can call.POSTverb : only authenticated user withwriterrole 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
organisationrealm - a
client1client inside theorganisationrealm - two roles
readerandwriter - three users with the same password
password:testhaving thewriterroletest2having thereaderroletest3without role
To use this app the following prerequisite are needed :
- docker
- docker-compose
- openjdk
$ docker-compose -f src/docker/docker-compose.yml buildRun 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 usersbutton to see users. - You should be able to access The role mappings page here of the user having the login
test. Click theClient Rolesdropdown and tapeclient1. You should see that the user have onlywriterintoAssigned Rolesselect.
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
appcontainer, run this command to test that the public endpoint is up and running.
$ curl "localhost:8081/" -s
Hello Public OkAlways inside the
appcontainer, 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 -rYou can decode the generated token using jwt.io web site
Always inside the
appcontainer, 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
appcontainer, run this command to test that
- With
testortest2bearer response is200.- With
test3bearer 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
appcontainer, run this command to test that
- With
testbearer response is200.- With
test2ortest3bearer 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_usernameSpring 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/organisationThe 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
@EnableWebSecuritywe activate this class. - we use the
EnableGlobalMethodSecurityannotation to enable thejsr250Enabledsupport, this is enable the support of@RolesAllowedannotation used intoContollerlevel. SessionCreationPolicyis set toSTATELESSto 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