Skip to content

Commit 84d5c67

Browse files
Add permissions handling (#3)
* Add permissions handling * Add documentation * Add hint about JWT verification * Use constant to avoid duplicating default values * Use equals with ignoreCase switch
1 parent 68e668f commit 84d5c67

File tree

12 files changed

+366
-41
lines changed

12 files changed

+366
-41
lines changed

README.md

+79
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,85 @@ override val router = router {
111111
}
112112
```
113113

114+
### Permissions
114115

116+
Permission handling is a cross-cutting concern that can be handled outside the regular handler function.
117+
The routing DSL also supports expressing required permissions:
118+
119+
```kotlin
120+
override val router = router {
121+
GET("/some", controller::get).requiringPermissions("A_PERMISSION", "A_SECOND_PERMISSION")
122+
}
123+
```
124+
125+
For the route above the `RequestHandler` checks if *any* of the listed permissions are found on a request.
126+
127+
Additionally we need to configure a strategy to extract permissions from a request on the `RequestHandler`.
128+
By default a `RequestHandler` is using the `NoOpPermissionHandler` which always decides that any required permissions are found.
129+
The `JwtPermissionHandler` can be used to extract permissions from a JWT token found in a header.
130+
131+
```kotlin
132+
class TestRequestHandlerAuthorization : RequestHandler() {
133+
override val router = router {
134+
GET("/some", controller::get).requiringPermissions("A_PERMISSION")
135+
}
136+
137+
override fun permissionHandlerSupplier(): (r: APIGatewayProxyRequestEvent) -> PermissionHandler =
138+
{ JwtPermissionHandler(
139+
request = it,
140+
//the claim to use to extract the permissions - defaults to `scope`
141+
permissionsClaim = "permissions",
142+
//separator used to separate permissions in the claim - defaults to ` `
143+
permissionSeparator = ","
144+
) }
145+
}
146+
```
147+
148+
Given the code above the token is extracted from the `Authorization` header.
149+
We can also choose to extract the token from a different header:
150+
151+
```kotlin
152+
JwtPermissionHandler(
153+
accessor = JwtAccessor(
154+
request = it,
155+
authorizationHeaderName = "custom-auth")
156+
)
157+
```
158+
159+
:warning: The implementation here assumes that JWT tokens are validated on the API Gateway.
160+
So we do no validation of the JWT token.
161+
162+
### Protobuf support
163+
164+
165+
### Open API validation support
166+
167+
The module `router-openapi-request-validator` can be used to validate a request against an [OpenAPI](https://www.openapis.org/) specification.
168+
Internally we use the [swagger-request-validator](https://bitbucket.org/atlassian/swagger-request-validator) to achieve this task.
169+
170+
This library validates:
171+
- if the resource used is documented in the OpenApi specification
172+
- if request and response can be successfully validated against the request and response schema
173+
- ...
174+
175+
```
176+
testImplementation 'com.github.moia-dev.lambda-kotlin-request-router:router-openapi-request-validator:0.3.1'
177+
```
178+
179+
```kotlin
180+
val validator = OpenApiValidator("openapi.yml")
181+
182+
@Test
183+
fun `should handle and validate request`() {
184+
val request = GET("/tests")
185+
.withHeaders(mapOf("Accept" to "application/json"))
186+
187+
val response = testHandler.handleRequest(request, mockk())
188+
189+
validator.assertValidRequest(request)
190+
validator.assertValidResponse(request, response)
191+
validator.assertValid(request, response)
192+
}
193+
```
115194

116195

router-openapi-request-validator/src/test/kotlin/io/moia/router/openapi/OpenApiValidatorTest.kt

+1-5
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,7 @@ class OpenApiValidatorTest {
6969

7070
override val router = Router.router {
7171
GET("/tests") { _: Request<Unit> ->
72-
ResponseEntity.ok(
73-
TestResponseInvalid(
74-
"Hello"
75-
)
76-
)
72+
ResponseEntity.ok(TestResponseInvalid("Hello"))
7773
}
7874
}
7975
}

router/build.gradle.kts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ dependencies {
44
compile(kotlin("stdlib-jdk8"))
55
compile(kotlin("reflect"))
66
compile("com.amazonaws:aws-lambda-java-core:1.2.0")
7-
compile("com.amazonaws:aws-lambda-java-events:2.2.5")
7+
compile("com.amazonaws:aws-lambda-java-events:2.2.6")
88

99
compile("org.slf4j:slf4j-api:1.7.26")
1010
compile("com.fasterxml.jackson.core:jackson-databind:2.9.8")

router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt

+3-3
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ fun APIGatewayProxyResponseEvent.withLocationHeader(request: APIGatewayProxyRequ
5656

5757
fun APIGatewayProxyResponseEvent.location() = getHeaderCaseInsensitive("location")
5858

59-
private fun getCaseInsensitive(key: String, map: Map<String, String>): String? =
60-
map.entries
61-
.firstOrNull { it.key.toLowerCase() == key.toLowerCase() }
59+
private fun getCaseInsensitive(key: String, map: Map<String, String>?): String? =
60+
map?.entries
61+
?.firstOrNull { key.equals(it.key, ignoreCase = true) }
6262
?.value
6363

6464
fun APIGatewayProxyResponseEvent.bodyAsBytes() = Base64.getDecoder().decode(body)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.moia.router
2+
3+
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
4+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
5+
import com.fasterxml.jackson.module.kotlin.readValue
6+
import java.util.Base64
7+
8+
interface PermissionHandler {
9+
10+
fun hasAnyRequiredPermission(requiredPermissions: Set<String>): Boolean
11+
}
12+
13+
class NoOpPermissionHandler : PermissionHandler {
14+
override fun hasAnyRequiredPermission(requiredPermissions: Set<String>) = true
15+
}
16+
17+
open class JwtAccessor(
18+
private val request: APIGatewayProxyRequestEvent,
19+
private val authorizationHeaderName: String = "authorization"
20+
) {
21+
22+
private val objectMapper = jacksonObjectMapper()
23+
24+
fun extractJwtToken(): String? =
25+
// support "Bearer <token>" as well as "<token>"
26+
request.getHeaderCaseInsensitive(authorizationHeaderName)?.split(" ")?.toList()?.last()
27+
28+
fun extractJwtClaims() =
29+
extractJwtToken()
30+
?.let { token -> token.split("\\.".toRegex()).dropLastWhile { it.isEmpty() } }
31+
?.takeIf { it.size == 3 }
32+
?.let { it[1] }
33+
?.let { jwtPayload ->
34+
try {
35+
String(Base64.getDecoder().decode(jwtPayload))
36+
} catch (e: Exception) {
37+
return null
38+
}
39+
}
40+
?.let { objectMapper.readValue<Map<String, Any>>(it) }
41+
}
42+
open class JwtPermissionHandler(
43+
val accessor: JwtAccessor,
44+
val permissionsClaim: String = defaultPermissionsClaim,
45+
val permissionSeparator: String = defaultPermissionSeparator
46+
) : PermissionHandler {
47+
48+
constructor(
49+
request: APIGatewayProxyRequestEvent,
50+
permissionsClaim: String = defaultPermissionsClaim,
51+
permissionSeparator: String = defaultPermissionSeparator
52+
) : this(JwtAccessor(request), permissionsClaim, permissionSeparator)
53+
54+
override fun hasAnyRequiredPermission(requiredPermissions: Set<String>): Boolean =
55+
extractPermissions().any { requiredPermissions.contains(it) }
56+
57+
internal open fun extractPermissions(): Set<String> =
58+
accessor.extractJwtClaims()
59+
?.let { it[permissionsClaim] }
60+
?.let {
61+
when (it) {
62+
is List<*> -> (it as List<String>).toSet()
63+
is String -> it.split(permissionSeparator).map { s -> s.trim() }.toSet()
64+
else -> null
65+
}
66+
}
67+
?: emptySet()
68+
69+
companion object {
70+
private const val defaultPermissionsClaim = "scope"
71+
private const val defaultPermissionSeparator: String = " "
72+
}
73+
}

router/src/main/kotlin/io/moia/router/RequestHandler.kt

+7
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ abstract class RequestHandler : RequestHandler<APIGatewayProxyRequestEvent, APIG
2929
val matchResult = routerFunction.requestPredicate.match(input)
3030
log.debug("match result for route '$routerFunction' is '$matchResult'")
3131
if (matchResult.match) {
32+
if (!permissionHandlerSupplier()(input).hasAnyRequiredPermission(routerFunction.requestPredicate.requiredPermissions))
33+
return createApiExceptionErrorResponse(input, ApiException("unauthorized", "UNAUTHORIZED", 401))
34+
3235
val handler: HandlerFunction<Any, Any> = routerFunction.handler
3336
return try {
3437
val requestBody = deserializeRequest(handler, input)
@@ -56,12 +59,16 @@ abstract class RequestHandler : RequestHandler<APIGatewayProxyRequestEvent, APIG
5659
objectMapper
5760
)
5861
)
62+
5963
open fun deserializationHandlers(): List<DeserializationHandler> = listOf(
6064
JsonDeserializationHandler(
6165
objectMapper
6266
)
6367
)
6468

69+
open fun permissionHandlerSupplier(): (r: APIGatewayProxyRequestEvent) -> PermissionHandler =
70+
{ NoOpPermissionHandler() }
71+
6572
private fun deserializeRequest(
6673
handler: HandlerFunction<Any, Any>,
6774
input: APIGatewayProxyRequestEvent

router/src/main/kotlin/io/moia/router/RequestPredicate.kt

+12-1
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,29 @@ data class RequestPredicate(
77
val method: String,
88
val pathPattern: String,
99
var produces: Set<String>,
10-
var consumes: Set<String>
10+
var consumes: Set<String>,
11+
var requiredPermissions: Set<String> = emptySet()
1112
) {
1213

1314
fun consuming(vararg mediaTypes: String): RequestPredicate {
1415
consumes = mediaTypes.toSet()
1516
return this
1617
}
18+
1719
fun producing(vararg mediaTypes: String): RequestPredicate {
1820
produces = mediaTypes.toSet()
1921
return this
2022
}
2123

24+
/**
25+
* Register required permissions for this route.
26+
* The RequestHandler checks if any of the given permissions are found on a request.
27+
*/
28+
fun requiringPermissions(vararg permissions: String): RequestPredicate {
29+
requiredPermissions = permissions.toSet()
30+
return this
31+
}
32+
2233
internal fun match(request: APIGatewayProxyRequestEvent) =
2334
RequestMatchResult(
2435
matchPath = pathMatches(request),

router/src/main/kotlin/io/moia/router/Router.kt

+8-28
Original file line numberDiff line numberDiff line change
@@ -20,44 +20,24 @@ class Router {
2020
).also { routes += RouterFunction(it, handlerFunction) }
2121

2222
fun <I, T> POST(pattern: String, handlerFunction: HandlerFunction<I, T>) =
23-
RequestPredicate(
24-
method = "POST",
25-
pathPattern = pattern,
26-
consumes = defaultConsuming,
27-
produces = defaultProducing
28-
).also {
29-
routes += RouterFunction(it, handlerFunction)
30-
}
23+
defaultRequestPredicate(pattern, "POST", handlerFunction)
3124

3225
fun <I, T> PUT(pattern: String, handlerFunction: HandlerFunction<I, T>) =
33-
RequestPredicate(
34-
method = "PUT",
35-
pathPattern = pattern,
36-
consumes = defaultConsuming,
37-
produces = defaultProducing
38-
).also {
39-
routes += RouterFunction(it, handlerFunction)
40-
}
26+
defaultRequestPredicate(pattern, "PUT", handlerFunction)
4127

4228
fun <I, T> DELETE(pattern: String, handlerFunction: HandlerFunction<I, T>) =
43-
RequestPredicate(
44-
method = "DELETE",
45-
pathPattern = pattern,
46-
consumes = defaultConsuming,
47-
produces = defaultProducing
48-
).also {
49-
routes += RouterFunction(it, handlerFunction)
50-
}
29+
defaultRequestPredicate(pattern, "DELETE", handlerFunction)
5130

5231
fun <I, T> PATCH(pattern: String, handlerFunction: HandlerFunction<I, T>) =
32+
defaultRequestPredicate(pattern, "PATCH", handlerFunction)
33+
34+
private fun <I, T> defaultRequestPredicate(pattern: String, method: String, handlerFunction: HandlerFunction<I, T>) =
5335
RequestPredicate(
54-
method = "PATCH",
36+
method = method,
5537
pathPattern = pattern,
5638
consumes = defaultConsuming,
5739
produces = defaultProducing
58-
).also {
59-
routes += RouterFunction(it, handlerFunction)
60-
}
40+
).also { routes += RouterFunction(it, handlerFunction) }
6141

6242
companion object {
6343
fun router(routes: Router.() -> Unit) = Router().apply(routes)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package io.moia.router
2+
3+
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
4+
import org.assertj.core.api.BDDAssertions.then
5+
import org.junit.jupiter.api.Test
6+
7+
class JwtPermissionHandlerTest {
8+
9+
/*
10+
{
11+
"sub": "1234567890",
12+
"name": "John Doe",
13+
"iat": 1516239022,
14+
"scope": "one two"
15+
}
16+
*/
17+
val jwtWithScopeClaimSpace = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZSI6Im9uZSB0d28ifQ.2tPrDymXDejHfVjNlVh4XUj22ZuDrKHP6dvWN7JNAWY"
18+
/*
19+
{
20+
"sub": "1234567890",
21+
"name": "John Doe",
22+
"iat": 1516239022,
23+
"userRights": "one, two"
24+
}
25+
*/
26+
val jwtWithCustomClaimAndSeparator = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJ1c2VyUmlnaHRzIjoib25lLCB0d28ifQ.49yk0fq39zMF77ZLJsXH_6D6I3iSDpy-Qk3vZ_PssIY"
27+
28+
@Test
29+
fun `should extract permissions from standard JWT contained in bearer auth header`() {
30+
val handler = permissionHandler("Bearer $jwtWithScopeClaimSpace")
31+
32+
thenRecognizesRequiredPermissions(handler)
33+
}
34+
35+
@Test
36+
fun `should extract permissions from standard JWT contained in auth header`() {
37+
val handler = permissionHandler(jwtWithScopeClaimSpace)
38+
39+
thenRecognizesRequiredPermissions(handler)
40+
}
41+
42+
@Test
43+
fun `should extract permissions from custom permissions claim`() {
44+
val handler = JwtPermissionHandler(
45+
accessor = JwtAccessor(APIGatewayProxyRequestEvent()
46+
.withHeader("Authorization", jwtWithCustomClaimAndSeparator)),
47+
permissionsClaim = "userRights",
48+
permissionSeparator = ","
49+
)
50+
51+
thenRecognizesRequiredPermissions(handler)
52+
}
53+
54+
@Test
55+
fun `should work for missing header`() {
56+
val handler = JwtPermissionHandler(JwtAccessor(APIGatewayProxyRequestEvent()))
57+
58+
then(handler.extractPermissions()).isEmpty()
59+
}
60+
61+
@Test
62+
fun `should work for not jwt auth header`() {
63+
val handler = permissionHandler("a.b.c")
64+
65+
then(handler.extractPermissions()).isEmpty()
66+
}
67+
68+
private fun thenRecognizesRequiredPermissions(handler: JwtPermissionHandler) {
69+
then(handler.hasAnyRequiredPermission(setOf("one"))).isTrue()
70+
then(handler.hasAnyRequiredPermission(setOf("two"))).isTrue()
71+
then(handler.hasAnyRequiredPermission(setOf("nope"))).isFalse()
72+
}
73+
74+
private fun permissionHandler(authHeader: String) =
75+
JwtPermissionHandler(JwtAccessor(APIGatewayProxyRequestEvent()
76+
.withHeader("Authorization", authHeader)))
77+
}

0 commit comments

Comments
 (0)