DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports Events Over 2 million developers have joined DZone. Join Today! Thanks for visiting DZone today,
Edit Profile Manage Email Subscriptions Moderation Admin Console How to Post to DZone Article Submission Guidelines
View Profile
Sign Out
Refcards
Trend Reports
Events
Zones
Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Partner Zones AWS Cloud
by AWS Developer Relations
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Partner Zones
AWS Cloud
by AWS Developer Relations
The Latest "Software Integration: The Intersection of APIs, Microservices, and Cloud-Based Systems" Trend Report
Get the report
  1. DZone
  2. Coding
  3. Frameworks
  4. Make Your Security Policy Auditable

Make Your Security Policy Auditable

In this article, learn how you can leverage OPA and Apache APISIX to move your authentication and authorization logic from the code to the infrastructure.

Nicolas Fränkel user avatar by
Nicolas Fränkel
CORE ·
Mar. 03, 23 · Analysis
Like (2)
Save
Tweet
Share
4.52K Views

Join the DZone community and get the full member experience.

Join For Free

Last week, I wrote about putting the right feature at the right place. I used rate limiting as an example, moving it from a library inside the application to the API Gateway. Today, I'll use another example: authentication and authorization.

Securing a Spring Boot Application

I'll keep using Spring Boot in the following because I'm familiar with it. The Spring Boot application offers a REST endpoint to check employees' salaries.

The specific use case is taken from the Open Policy Agent site (more later):

Create a policy that allows users to request their own salary as well as the salary of their direct subordinates.

We need a way to:

  1. Authenticate an HTTP request as coming from a known user. 
  2. Check whether the user has access to the salary data. 

In any other case, return a 401.

I'll pass an authentication token in the request to keep things simple. I won't rely on a dedicated authentication/authorization backend, such as Keycloak, but it should be a similar approach if you do.

To enable Spring Security on the app, we need to add the Spring Boot Security Starter.

XML
 
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>


We also need to enable Spring Security to work its magic:

Kotlin
 
@SpringBootApplication
@EnableWebSecurity
class SecureBootApplication


With those two steps in place, we can start securing the application according to the above requirement:

Kotlin
 
internal fun security() = beans {                                       //1
    bean {
        val http = ref<HttpSecurity>()
        http {
            authorizeRequests {
                authorize("/finance/salary/**", authenticated)          //2
            }
            addFilterBefore<UsernamePasswordAuthenticationFilter>(
                TokenAuthenticationFilter(ref())                        //3
            )
            httpBasic { disable() }
            csrf { disable() }
            logout { disable() }
            sessionManagement {
                sessionCreationPolicy = SessionCreationPolicy.STATELESS
            }
        }
        http.build()
    }
    bean { TokenAuthenticationManager(ref(), ref()) }                   //4
}


  1. Use the Kotlin Beans DSL - because I can.
  2. Only allow access to the endpoint to authenticated users. 
  3. Add a filter in the filter chain to replace regular authentication.
  4. Add a custom authentication manager. 

Requests look like the following:

Shell
 
curl -H 'Authorization: xyz'  localhost:9080/finance/salary/bob


The filter extracts from the request the necessary data used to decide whether to allow the request or not:

Kotlin
 
internal class TokenAuthenticationFilter(authManager: AuthenticationManager) :
    AbstractAuthenticationProcessingFilter("/finance/salary/**", authManager) {

    override fun attemptAuthentication(req: HttpServletRequest, resp: HttpServletResponse): Authentication {
        val header = req.getHeader("Authorization")                   //1
        val path = req.servletPath.split('/')                         //2
        val token = KeyToken(header, path)                            //3
        return authenticationManager.authenticate(token)              //4
    }

    // override fun successfulAuthentication(
}


  1. Get the authentication token. 
  2. Get the path.
  3. Wrap it under a dedicated structure. 
  4. Try to authenticate the token.

In turn, the manager tries to authenticate the token:

Kotlin
 
internal class TokenAuthenticationManager(
    private val accountRepo: AccountRepository,
    private val employeeRepo: EmployeeRepository
) : AuthenticationManager {
  override fun authenticate(authentication: Authentication): Authentication {
    val token = authentication.credentials as String? ?:                       //1
        throw BadCredentialsException("No token passed")
    val account = accountRepo.findByPassword(token).orElse(null) ?:            //2
        throw BadCredentialsException("Invalid token")
    val path = authentication.details as List<String>
    val accountId = account.id
    val segment = path.last()
    if (segment == accountId) return authentication.withPrincipal(accountId)   //3
    val employee = employeeRepo.findById(segment).orElse(null)                 //4
    val managerUserName = employee?.manager?.userName
    if (managerUserName != null && managerUserName == accountId)               //5
        return authentication.withPrincipal(accountId)                         //5
    throw InsufficientAuthenticationException("Incorrect token")               //6
  }
}


  1. Get the authorization token passed from the filter. 
  2. Try to find the account that has this token. For simplicity's sake, the token is stored in plain text without hashing. 
  3. If the account tries to access its data, allow it. 
  4. If not, we must load the hierarchy from another repo. 
  5. If the account attempts to access data from an employee they manage, allow it. 
  6. Else, deny it. 

The whole flow can be summarized as the following:

Flow to Securing a Spring Boot Application

Now, we can try some requests.

Shell
 
curl -H 'Authorization: bob' localhost:9080/finance/salary/bob


bob asks for his own salary, and it works.

Shell
 
curl -H 'Authorization: bob' localhost:9080/finance/salary/alice


bob asks for the salary of one of his subordinates, and it works as well.

Shell
 
curl -H 'Authorization: bob' localhost:9080/finance/salary/alice


alice asks for her manager's salary, which is not allowed.

The code above works perfectly but has one big issue: there's no way to audit the logic. One must know Kotlin and how Spring Security works to ensure the implementation is sound.

Introducing Open Policy Agent

Open Policy Agent, or OPA for short, describes itself as "Policy-based control for cloud-native environments."

Stop using a different policy language, policy model, and policy API for every product and service you use. Use OPA for a unified toolset and framework for policy across the cloud native stack.

Whether for one service or for all your services, use OPA to decouple policy from the service's code so you can release, analyze, and review policies (which security and compliance teams love) without sacrificing availability or performance.

- OPA Website

In short, OPA allows writing policies and offers a CLI and a daemon app to evaluate them.

You write policies in a specific interpreted language named Rego, and I must admit it's not fun. Anyway, here's our above policy written in "clear" text:

PHP
 
package ch.frankel.blog.secureboot

employees := data.hierarchy                                 //1

default allow := false

# Allow users to get their own salaries.
allow {
    input.path == ["finance", "salary", input.user]         //2
}

# Allow managers to get their subordinates' salaries.
allow {
    some username
    input.path = ["finance", "salary", username]            //3
    employees[input.user][_] == username                    //3
}


  1. Get the employee hierarchy somehow (see below). 
  2. If the account requests their salary, allow access. 
  3. If the account requests the salary of a subordinate, allow access. 

I used two variables in the snippet above: input and data. input is the payload that the application sends to OPA. It should be in JSON format and has the following form:

JSON
 
{
    "path": [
        "finance",
        "salary",
        "alice"
    ],
    "user": "bob"
}


More Open Policy Agent Goodness

However, OPA can't decide on the input alone, as it doesn't know the employee's hierarchy. One approach would be to load the hierarchy data on the app and send it to OPA. A more robust approach is to let OPA access external data to separate responsibilities cleanly. OPA offers many options to achieve it. Here, I pretend to extract data from the Employee database, bundle it together with the policy file, serve the bundle via HTTP, and configure OPA to load it at regular intervals.

Extract data from the Employee database, bundle it together with the policy file, serve the bundle via HTTP, and configure OPA to load it at regular intervals.

Note that you shouldn't use Apache APISIX only to serve static files. But since I'll be using it in the next evolution of my architecture, I want to avoid having a separate HTTP server to simplify the system.

Now that we moved the decision logic to OPA, we can replace our code with a request to the OPA service. The new version of the authentication manager is:

Kotlin
 
internal class OpaAuthenticationManager(
    private val accountRepo: AccountRepository,
    private val opaWebClient: WebClient
) : AuthenticationManager {

    override fun authenticate(authentication: Authentication): Authentication {
        val token = authentication.credentials as String? ?:                       //1
            throw BadCredentialsException("No token passed")
        val account = accountRepo.findByPassword(token).orElse(null) ?:            //1
            throw BadCredentialsException("Invalid token")
        val path = authentication.details as List<String>
        val decision = opaWebClient.post()                                         //2
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(OpaInput(DataInput(account.id, path)))                      //3
            .exchangeToMono { it.bodyToMono(DecisionOutput::class.java) }          //4
            .block() ?: DecisionOutput(ResultOutput(false))                        //5
        if (decision.result.allow) return authentication.withPrincipal(account.id) //6
        else throw InsufficientAuthenticationException("OPA disallow")             //6
    }
}


  1. Keep the initial authentication logic. 
  2. Replace the authorization with a call to the OPA service. 
  3. Serialize the data to conform to the JSON input that the OPA policy expects. 
  4. Deserialize the result. 
  5. If something is wrong, the default should be to disallow. 
  6. Abide by OPA's result. 

The flow is now the following:

Revised flow

At this point, we moved the authorization logic from the code to OPA.

Moving Authentication to the API Gateway

The next and final step is to move the authentication logic. The obvious candidate is the API Gateway since we set Apache APISIX in the previous step. In general, we should use the capabilities of the API Gateway as much as possible and fall back to libraries for the rest.

Apache APISIX has multiple authentication plugins available. Because I used a bearer token, I'll use key-auth. Let's create our users, or in Apache APISIX terms, consumers:

YAML
 
consumers:
  - username: alice
    plugins:
      key-auth:
        key: alice
  - username: betty
    plugins:
      key-auth:
        key: betty
  - username: bob
    plugins:
      key-auth:
        key: bob
  - username: charlie
    plugins:
      key-auth:
        key: charlie


Now, we can protect the Spring Boot upstream:

YAML
 
routes:
  - uri: /finance/salary*
    upstream:
      type: roundrobin
      nodes:
        "boot:8080": 1
    plugins:
      key-auth:
        header: Authorization                    #1
      proxy-rewrite:
        headers:
          set:
            X-Account: $consumer_name            #2


  1. Authenticate with key-auth and the Authorization header. 
  2. Sets the consumer id in the X-Account HTTP header for the upstream. 

APISIX guarantees that requests that reach the Spring Boot app are authenticated. The code only needs to call the OPA service and follow the decision. We can entirely remove Spring Security and replace it with a simple filter:

Kotlin
 
bean {
    val repo = ref<EmployeeRepository>()
    router {
        val props = ref<AppProperties>()
        val opaWebClient = WebClient.create(props.opaEndpoint)
        filter { req, next -> validateOpa(opaWebClient, req, next) }
        GET("/finance/salary/{user_name}") {
          // ...
        }
    }
}

internal fun validateOpa(
    opaWebClient: WebClient,
    req: ServerRequest,
    next: (ServerRequest) -> ServerResponse
): ServerResponse {
    val httpReq = req.servletRequest()
    val account = httpReq.getHeader("X-Account")                           //1
    val path = httpReq.servletPath.split('/').filter { it.isNotBlank() }
    val decision = opaWebClient.post()                                     //2
        .accept(MediaType.APPLICATION_JSON)
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(OpaInput(DataInput(account, path)))
        .exchangeToMono { it.bodyToMono(DecisionOutput::class.java) }
        .block() ?: DecisionOutput(ResultOutput(false))
    return if (decision.result.allow) next(req)
    else ServerResponse.status(HttpStatus.UNAUTHORIZED).build()
}


  1. Get the account name from the API Gateway. 
  2. Nothing changes afterward. 

The final flow is the following:

Final Flow

Conclusion

Everything looks like a nail when all you've got is a hammer. Developers' mighty hammer of choice is code. I've written tons of code to solve problems, and later on, I've used even more libraries to solve even more problems. As you evolve from developer to architect, you increase the number of tools you have. In this regard, code is only one tool among many. Your organization has many infrastructure tools you can leverage to develop solutions at minimal costs.

In this post, I've shown how you can leverage OPA and Apache APISIX to move your authentication and authorization logic from the code to the infrastructure. The former allows you to audit your security policies, the latter coherence among all your upstream across all tech stacks.

The complete source code for this post can be found on GitHub.

To Go Further

  • Spring Security
  • OPA Bundles
  • Rego playground
  • Spring Security Authorization with OPA
Spring Security authentication Spring Boot

Published at DZone with permission of Nicolas Fränkel, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

Popular on DZone

  • What Is Enterprise Portal and How to Develop One?
  • 8 Proven Ways to Combat End-of-Life Software Risks
  • MongoDB Time Series Benchmark and Review
  • Unlocking the Power of ChatGPT

Comments

Partner Resources

X

ABOUT US

  • About DZone
  • Send feedback
  • Careers
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 600 Park Offices Drive
  • Suite 300
  • Durham, NC 27709
  • support@dzone.com
  • +1 (919) 678-0300

Let's be friends: