Background
In any system where users interacting with each other, authorization & authentication are the key elements that controls what each user can do. To read more on authentication and authorization try RBAC. To demonstrate the usecases, I present few roles and resources below. This is an e-commerce website.
Role | Remarks |
---|---|
Visitor | There is no session yet, user can enroll in the system to become Buyer/Seller |
Buyer | Buyer can place order and update his address-profile |
Seller | Seller can add product and update order status once Buyer place an order |
For simplicity resources are just few GraphQL mutations and users are hardcoded in the system. You can get the sample project source at github.
Let’s start with graphql schema and move towards authentication.
🧑💻 Code it
Ground preparation
First define a schema with few queries that meant for each role. They just return a string when each mutation is invoked. Catch is each user need specific auth token to access the resource.
1
2
3
4
5
type Mutation {
registerUser: String
placeOrder: String
addProduct: String
}
And the respective DGSComponent that expose the resource looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@DgsComponent
class DummyResource {
@DgsMutation
fun registerUser(): String = "dummy-member-id"
@DgsMutation
fun placeOrder(): String = "dummy-order-id"
@DgsMutation
fun addProduct(): String = "dummy-product-id"
}
In a typical system, each of them will need input, since we focus on roles and how do we protect each mutation from other roles, all other parts are omitted for brevity. We expect the system to throw error when authentication fails. Below is the DummyUserService
class where system identifies user role given an auth-token.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
class DummyUserService {
val sessions = mapOf(
"token-buyer" to Roles.BUYER,
"token-seller" to Roles.SELLER
)
fun identifyRole(token: String?): Roles {
return sessions[token] ?: Roles.VISITOR
}
}
enum class Roles {
VISITOR,
BUYER,
SELLER
}
Above wraps up the overall, setup for roles and resources. Let’s wire up authentication.
Components to know
We’ll use following components to set up authentication. They are born of springboot & aspectj and not specific to graphql. So, even RestController can benefit using the below ones. Oneliner on each piece before start implementing them. How do they interact is illustrated in below diagram.
Components in orange will be created by us. Green one is provided by springboot. And the blue wrapper is generated by aspectj for us.
Filters
Filters are the requst interceptors. They can process any incoming servlet request before it reaches the resolver (in our case the mutation). We’ll use OncePerRequestFilter
to augment our incoming request with Role related information.
RequestContextHolder
RequestContextHolder
can hold session related information (user id, token, role) for a servlet request. However, we own the logic to build a session context. At any point of time, this can be accessed within the system to identify session.
RequestManager
This is created by us to wrap RequestContextHolder
and provide nice interface to other components in play. Business logic to build a session context is done here.
Aspect [AOP]
Aspect oriented programming is a programming paradigm where any component can be augmented to provide common functionalities, so that each controller (mutation) doesn’t have to hold any repeating business logic (authentication). This is achieved by creating proxy classes around the target class. Head over to wiki to know more about this.
That covers each component and it’s role in authentication. Let’s code to have further clarity. I’ll build bottom up to so that we’ll know the dependency between each.
RequestManager
Requst manager reads header from incoming request and store it to RequestContextHolder
. It uses UserService
created in Ground preparation section. It also hosts few helper methods to ease up session data retrieval in latter parts. Code should be self-explanatory with inline comments.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component
class DummyRequestManager {
@Autowired
private lateinit var userService: DummyUserService
// Save the session info per request. Retrieve it throughout the request
fun saveSession(request: HttpServletRequest) {
// Retrieve auth token from request - if any
val token: String? = request.getHeader(HEADER_TOKEN)
// Identify role
val role = userService.identifyRole(token)
// attribute the request with session. This will be available throughout the session
request.setAttribute(
KEY_SESSION, DummySession(
role = role
)
)
}
// A non-null guaranteed session. Everyone has a role here.
fun getSession(): DummySession {
val session = RequestContextHolder
.getRequestAttributes()!!.getAttribute(KEY_SESSION, RequestAttributes.SCOPE_REQUEST)
return session as DummySession
}
/**
* Convenience method to retrieve role
*/
fun getUserRole(): Roles = getSession().role
companion object {
private const val KEY_SESSION = "userSession"
private const val HEADER_TOKEN = "x-auth-token"
}
}
data class DummySession( val role: Roles)
Request filter
Request filters are the interceptors for any request. Logging and session creation are the common usecase for this component. Let’s create a DummyRequestFilter
and invoke request manager to create session.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Interceptor that executed once per http request
*/
@Component
class DummyRequestFilter : OncePerRequestFilter() {
@Autowired
private lateinit var requestManager: DummyRequestManager
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// Feed the request to request manager for session preparation
requestManager.saveSession(request)
// Resume with request
filterChain.doFilter(request, response)
}
}
Custom annotations
With above two pieces in place, we identified user role per request. Next is to retrict access for each mutation. Let’s create few annotations for each role.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class BuyerOnly
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class SellerOnly
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class VisitorOnly
Marking the territory for each user.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
File: "DummyResource.kt"
@DgsComponent
class DummyResource {
@VisitorOnly
@DgsMutation
fun registerUser(): String = "dummy-member-id"
@BuyerOnly
@DgsMutation
fun placeOrder(): String = "dummy-order-id"
@SellerOnly
@DgsMutation
fun addProduct(): String = "dummy-product-id"
}
Aspect
We have session and resource marked for each role. All that’s left is to read the annotation and role and authenticate requests. For this purpose, we have Aspects — aspects can run before-after-even around a pointcut. Here, pointcut refers to our mutation method — the safety net logic that we execute before mutation is called advice. Together they’re called aspect. So, our final piece “the aspect” is here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Component
@Aspect
class DummyAspect {
@Autowired
private lateinit var requestManager: DummyRequestManager
@Before("@annotation(SellerOnly)")
fun restrictSellerOnly(joinPoint: JoinPoint) {
val role = requestManager.getUserRole()
if (role != Roles.SELLER) {
throw GraphQLException("This operation is specific to Seller accounts")
}
}
@Before("@annotation(BuyerOnly)")
fun restrictBuyerOnly(joinPoint: JoinPoint) {
val role = requestManager.getUserRole()
if (role != Roles.BUYER) {
throw GraphQLException("This operation is specific to Buyer accounts")
}
}
@Before("@annotation(VisitorOnly)")
fun restrictVisitorOnly(joinPoint: JoinPoint) {
val role = requestManager.getUserRole()
if (role != Roles.BUYER) {
throw GraphQLException("Please logout from existing session")
}
}
}
That’s it.
🚀 Run it
Try running the mutation in curl or graphiQL playground [http://localhost:8080/graphiql].
1
2
3
4
5
6
7
8
9
10
// Seller adds product succussfully
curl 'http://localhost:8080/graphql' \
-H 'x-auth-token: token-seller' \
-H 'Content-Type: application/json' \
--data-raw '{"query":"mutation { addProduct }","variables":null}' \
--compressed
{"data":{"addProduct":"dummy-product-id"}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Buyer cannot add product
curl 'http://localhost:8080/graphql' \
-H 'x-auth-token: token-buyer' \
-H 'Content-Type: application/json' \
--data-raw '{"query":"mutation {\n \n addProduct\n \n}","variables":null}' \
--compressed
{
"errors": [
{
"message": "This operation is specific to Seller accounts",
"locations": [],
"extensions": {
"errorType": "UNKNOWN"
}
}
],
"data": {
"addProduct": null
}
}
Why do we need method level control?
Following query is possible with graphql.
1
2
3
4
5
6
7
8
9
mutation {
addProduct
placeOrder
}
### header
"x-auth-token":"token-seller"
In a single graphQL http-request you can stack up multiple operations. In such cases, only one operation fails while the other execute successfully. Pretty cool right 😎?.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"errors": [
{
"message": "This operation is specific to Buyer accounts",
"locations": [],
"extensions": {
"errorType": "UNKNOWN"
}
}
],
"data": {
"addProduct": "dummy-product-id",
"placeOrder": null
}
}
Future scope
With this scaffold we can build resources with granular control. With simple annotations added to each method, it is possible to restrict nested objects without modifying much to the business logic in resolvers.
For this example, we took Role
in focus. Let’s say I need to create a resource specific to a user, like an address. I don’t have to (shouldn’t) pass in user-id in the input. It is cross verified against the token and available in session. A full context like user’s client/locale etc. will be availble to tailor perfect response.
In a reallife system, the userservice would be table query or federated service provider. For performance, the session information should be cached and remote calls must be avoided to get bit of performance boost.