Contents
Problem Statement
We will develop a Spring Boot microservice with 2 APIs /sayhello and /saysecret. Clients having a valid access token from the authentication server (using Okta) will be authorized (using OPA) to perform only /sayhello API.
Okta Introduction
- Okta (https://www.okta.com/) is a secure identity cloud that links all apps logins and devices into a single pane of glass
- Okta runs in the cloud and integrates with directories and identity management systems
- Okta features include Single Sign On (SSO), LDAP integration, Multi factor authentication (MFA) etc.
- Okta Single Sign-On lets users access any app with a single set of credentials
- Okta plays the role of authentication server for secure access to API and microservices by authenticating the user and providing access token
The usual OAuth 2.0 grant flow looks like this:
Step 5: Now the app for integration is ready. Note down the Okta domain, Client ID and Client Secret for integration into microservice.
Step 6: Navigate to Security/ API in Okta dashboard and configure the required scopes. We will use default authorization server for this example.
Step 7: Edit the default authorization server and configure the. required scopes. In this example, we have added a new scope "read_sayhello" which we will use in our application.
Create a Spring starter project and include the Web application starter, Spring Boot Security starter, Okta Spring Boot starter and Spring Boot WebFlux as dependencies to the pom.xml. Update the application.properties to point to the Okta issuer. Configure the Okta client id and client secret received during application registration. In the Spring application we have included 2 APIs /sayhello and /saysecret to test the integration with OPA.
Now let us look at the security configuration for this application. To enable HTTP security in Spring we need to extend the WebSecurityConfigurerAdapter and provide the configuration. Here we have used an Access Decision Manager for authorization decisions which contains the OPA based voter. We have indicated in the security config that it uses JWT as the token verification strategy. Also note that we have used the Rego package name created earlier on OPA service in the Auth URL.
- Client requests authorization from the resource owner (usually the user).
- If the user gives authorization, the client passes the authorization grant to the authorization server (in this case Okta).
- If the grant is valid, the authorization server returns an access token, possibly alongside a refresh and/or ID token.
- The client now uses that access token to access the resource server.
Open Policy Agent (OPA) Introduction
- The Open Policy Agent (OPA) is an open source, general-purpose policy engine that can be leveraged for policy enforcement on microservices.
- OPA provides a high-level declarative language (Rego) that lets to specify policy as code and supports simple APIs to offload policy decision-making from the microservices.
- In a typical scenario, when a microservice needs to make policy decisions (E.g. Is the user authorized to perform this API request) it queries OPA and supplies JSON data as input. OPA evaluates the policy decisions by evaluating the input against configured policies and data. OPA response can be configured to provide yes/ no or allow/ deny decisions.
- Rego (https://www.openpolicyagent.org/docs/latest/policy-language/) is the declarative query language to define policies. Rego provides support for referencing nested documents and ensures queries are correct and unambiguous.
Let us say we are writing a microservice which exposes APIs for the clients to consume. OPA is used to enforce the authorization policy for the APIs.
- OPA gets bootstrapped with a Policy document written in Rego which contains the authorization rules. OPA supports Policy API which can be used to add, modify or delete policies.
- OPA policies make policy decisions based on hierarchical structured data. Data can be loaded from outside world and this is referred as base documents. There are also documents computed by policies which are referred as virtual documents. OPA supports Data API to read, write and update documents.
- Microservice queries OPA for a decision. Query is typically accompanied with attributes like HTTP method, path, user etc.
- OPA uses the policies and data documents and responds with an allow or deny decision to the requesting microservices.
Register application in Okta
In this section we will look at the steps required to register an application in Okta. We are building a server side application which would use OAuth 2.0 authentication through API endpoints.
Step 1: Sign in to Okta developer account https://developer.okta.com
Step 2: Create a Web App using "Add Web App".
Step 6: Navigate to Security/ API in Okta dashboard and configure the required scopes. We will use default authorization server for this example.
Step 8: Get access token from Okta
Validate the Okta configuration by requesting for access token.
curl --location --request POST 'https://dev-33448303.okta.com/oauth2/default/v1/token?grant_type=client_credentials&client_id=[client-id]&client_secret=[client_secret]&scope=read_sayhello' \ > --header 'Content-Type: application/x-www-form-urlencoded'
Now we have the Okta setup ready for integration.
OPA as Docker container
It is easy to run OPA as a Docker container. The command below brings up an instance of OPA engine on port 8181.$ docker run -p 8181:8181 openpolicyagent/opa run --server --log-level debug {"addrs":[":8181"],"diagnostic-addrs":[],"level":"info","msg":"Initializing server.","time":"2021-12-07T06:04:19Z"} {"level":"debug","msg":"maxprocs: Leaving GOMAXPROCS=6: CPU quota undefined","time":"2021-12-07T06:04:19Z"} {"headers":{"Content-Type":["application/json"],"User-Agent":["Open Policy Agent/0.35.0 (linux, amd64)"]},"level":"debug","method":"POST","msg":"Sending request.","time":"2021-12-07T06:04:19Z","url":"https://telemetry.openpolicyagent.org/v1/version"} {"level":"debug","msg":"Server initialized.","time":"2021-12-07T06:04:19Z"} {"headers":{"Content-Length":["216"],"Content-Type":["application/json"],"Date":["Tue, 07 Dec 2021 06:04:21 GMT"]},"level":"debug","method":"POST","msg":"Received response.","status":"200 OK","time":"2021-12-07T06:04:21Z","url":"https://telemetry.openpolicyagent.org/v1/version"} {"current_version":"0.35.0","level":"debug","msg":"OPA is up to date.","time":"2021-12-07T06:04:21Z"}Validate if OPA service is running by accessing http://localhost:8181/ which should display the OPA page.
Now that we have OPA service running, next step is to deploy a policy. Let us create a sample Rego policy which denies all requests except the /sayhello GET API.
package demoapi.authz default allow = false allow { input.method == "GET" input.api == "/sayhello" token.payload.scp[_] = "read_sayhello" } token = {"payload": payload} { [header, payload, signature] := io.jwt.decode(input.jwt.tokenValue) }Next step is to deploy this policy on to the OPA service. We will use the OPA REST APIs for this https://www.openpolicyagent.org/docs/latest/rest-api/.
Use the "Create Policy" API to deploy the policy.
Rego playground can be used to validate the policy against the token from Okta server.
Copy the policy created earlier into the playground. Specify the input JSON fields and provide the access token received from Okta earlier. If the API is "/sayhello" policy validates to allow as true.
Now that we have working Okta and OPA services, let us move on to develop the Spring Boot microservice. Basic understanding of Spring authorization architecture and associated classes is important to work with this example.
Access Decision Manager
- Access Decision Manager make a final access control authorization decision.
- Access Decision Manager contains a list of access decision voter responsible for voting on authorization decisions.
- There are three concrete AccessDecisionManager provided with Spring Security that tally the votes.
- The ConsensusBased implementation will grant or deny access based on the consensus of non-abstain votes.
- The AffirmativeBased implementation will grant access if one or more ACCESS_GRANTED votes were received (i.e. a deny vote will be ignored, provided there was at least one grant vote).
- The UnanimousBased provider expects unanimous ACCESS_GRANTED votes in order to grant access, ignoring abstains. It will deny access if there is any. ACCESS_DENIED vote.
Access Decision Voter
- Access Decision Voter is responsible for voting on authorization decisions.
- A voting implementation will return:
- ACCESS_ABSTAIN if it has no opinion on an authorization decision.
- If it does have an opinion, it must return either ACCESS_DENIED or ACCESS_GRANTED.
More details refer Spring Authorization architecture:
Spring Boot dependencies
<dependencies> <dependency> <groupId>com.okta.spring</groupId>
<artifactId>okta-spring-boot-starter</artifactId>
</dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency> </dependencies>
Configure application.properties
okta.oauth2.issuer=https://dev-33448303.okta.com/oauth2/default okta.oauth2.client-id=0oa2yn4wxkbbrtwPH5d7 okta.oauth2.client-secret=[client secret]
Define the Controller
package com.stackstalk.security; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController @SpringBootApplication public class MySecureAppApplication { public static void main(String[] args) { SpringApplication.run(MySecureAppApplication.class, args); } @GetMapping(path="/sayhello") public String sayhello(JwtAuthenticationToken user) { System.out.println(user.toString()); return "Hello there!! "; } @GetMapping(path="/saysecret") public String saysecret(JwtAuthenticationToken user) { System.out.println(user.toString()); return "This is a secret!! "; } }
Define the Security configuration
package com.stackstalk.security; import java.util.Arrays; import java.util.List; import org.springframework.context.annotation.Configuration; import org.springframework.security.access.AccessDecisionManager; import org.springframework.security.access.AccessDecisionVoter; import org.springframework.security.access.vote.UnanimousBased; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/").permitAll() .anyRequest().authenticated() .accessDecisionManager(accessDecisionManager()) .and() .oauth2ResourceServer().jwt(); } public AccessDecisionManager accessDecisionManager() { List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList( new OPABasedVoter("http://localhost:8181/v1/data/demoapi/authz/allow")); return new UnanimousBased(decisionVoters); } }
Define the Access Decision Voter
Now let us review the implementation of OPA based voter. Here we extract the HTTP method, API and JWT token and make a POST call on the OPA Auth URL. Based on the allow/ deny decision from OPA the voter makes a decision to GRANT or DENY access to API.
package com.stackstalk.security; import java.util.Collection; import java.util.HashMap; import java.util.Map; import org.springframework.security.access.AccessDecisionVoter; import org.springframework.security.access.ConfigAttribute; import org.springframework.security.core.Authentication; import org.springframework.security.web.FilterInvocation; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; public class OPABasedVoter implements AccessDecisionVoter<Object> { private String opaAuthUrl; OPABasedVoter(String opaAuthUrl) { this.opaAuthUrl = opaAuthUrl; } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public boolean supports(Class clazz) { return true; } @Override public int vote(Authentication authentication, Object object, Collection attributes) { if (!(object instanceof FilterInvocation)) { return ACCESS_ABSTAIN; } FilterInvocation filter = (FilterInvocation) object; Map<String, Object> input = new HashMap<String, Object>(); input.put("method", filter.getRequest().getMethod()); input.put("api", filter.getRequest().getRequestURI()); input.put("jwt", authentication.getPrincipal()); WebClient webClient = WebClient.create(); OPAResponse response = webClient.post() .uri(this.opaAuthUrl) .body(Mono.just(new OPARequest(input)), OPARequest.class) .retrieve() .bodyToMono(OPAResponse.class) .block(); if (response == null || response.getResult() == false) { return ACCESS_DENIED; } return ACCESS_GRANTED; } }
We have also used request/ response classes.
package com.stackstalk.security; import java.util.Map; public class OPARequest { Map<String, Object> input; public OPARequest(Map<String, Object> input) { this.input = input; } public Map<String, Object> getInput() { return input; } }
package com.stackstalk.security; public class OPAResponse { private boolean result; public OPAResponse() { } public boolean getResult() { return this.result; } }
Putting it all together and testing
Now, let us test the application and examine.GET Access Token
This API is used to request for an access token from Okta.$ curl --location --request POST 'https://dev-33448303.okta.com/oauth2/default/v1/token?grant_type=client_credentials&client_id=add_client_id_here&client_secret=add_client_secret_here&scope=read_sayhello' --header 'Content-Type: application/x-www-form-urlencoded' Returns the access token from Okta
GET request on /sayhello
This API is allowed by the OPA policy and responds with the output.$ curl --location --request GET 'http://localhost:8080/sayhello' --header 'Authorization: Bearer add_access_token_here' Hello there!!
GET request on /saysecret
This API is denied by the OPA policy and responds with the 403 status code.$ curl --location --request GET 'http://localhost:8080/saysecret' --header 'Authorization: Bearer add_access_token_here' No output. Status code 403.
Conclusion
Okta plays the role of authentication server for secure access to API and microservices by authenticating the user and providing access token. OPA lets to specify policy as code and supports simple APIs to offload policy decision-making from the microservices. Spring authorization architecture classes could be easily leveraged with Spring Boot microservices to implement secure APIs.
Get access to the full source code of this example in GitHub.
0 comments:
Post a Comment