In the previous post we covered authentication and role management. However, it assumed any invalid session to be a visitor instead of throwing 401—Unauthorized client error. This post covers the token expiry aspect of API call.
Let's start with coding part. Following block diagram explains the interaction between each component. As usual, we'll build the system bottom-up.
🎟 Token Service
To manage session and expire it, we need meta attributes other than just the token. So, let's beef up the token to a data class called SessionToken. This will hold created & last access timestamps in addition to the token itself.
data class SessionToken(
val token: String,
val created: OffsetDateTime,
var lastAccess: OffsetDateTime = created
)
Our token service is a simple repository which abstracts CRUD on token. When user login-register, a new token generated and wrapped in SessionToken
. For every web request made, the token updated with last access time. In case user logout or token expired, entry will be deleted.
Below is our simple implementation — we store our mock tokens as usual to keep our focus on expiry. The created time points to now()
i.e we emulate like user logged in at the time of launch.
@Service
class TokenService {
private val store = mutableMapOf<String, SessionToken>()
init {
store["token-buyer"] =
SessionToken("token-buyer", OffsetDateTime.now())
store["token-seller"] =
SessionToken("token-seller", OffsetDateTime.now())
}
fun createToken() = SessionToken(
token = UUID.randomUUID().toString(),
created = OffsetDateTime.now()
)
fun peekToken(token: String?): SessionToken? = store[token]
fun updateLastAccessTime(token: String) {
// TODO: Token not found in store - fishy!!
store[token]?.lastAccess = OffsetDateTime.now()
}
fun deleteToken(token: String?) {
store.remove(token)
}
}
⏲ Token Expiry Strategy
When it comes to expiry, it's always a good practice to abstract out the validation part and create functional interface to carry out the validation. This will help us plug-in different implementations in the system with little config change.
This brings us to a functional interface SessionExpiryStrategy
which takes a SessionToken
validate and return whether it is expired.
interface SessionExpiryStrategy {
fun isSessionExpired(token: SessionToken): Boolean
}
An implementation is only good when explained with its implementations. We consider three different strategies for this.
- Fixed lifespan : Session will expire after 30 days
- Timeout : If user is not active for 3 minutes — logout
- Extended Lifespan: If user is active during the 30th day, give him grace period in 3 minute window
@Component("fixed_time")
class FixedLifeSpanStrategy : SessionExpiryStrategy {
// 30 days fixed lifespan
val MAX_DURATION = 30 * 24 * 60 * 60
override fun isSessionExpired(token: SessionToken): Boolean {
val durationSinceLogin = ChronoUnit.SECONDS.between(
token.created,
OffsetDateTime.now()
)
return durationSinceLogin > MAX_DURATION
}
}
@Component("timeout")
class TimeoutExpiryStrategy : SessionExpiryStrategy {
// Logout after 3 minutes of inactivity
const val MAX_DURATION = 3 * 60
override fun isSessionExpired(token: SessionToken): Boolean {
val durationSinceLastAccess = ChronoUnit.SECONDS.between(
token.lastAccess,
OffsetDateTime.now()
)
return durationSinceLastAccess > MAX_DURATION
}
}
@Component("extended")
class ExtendedLifeSpanStrategy : SessionExpiryStrategy {
private val timeoutExpiryStrategy = TimeoutExpiryStrategy()
private val fixedLifeSpanStrategy = FixedLifeSpanStrategy()
override fun isSessionExpired(token: SessionToken): Boolean {
return if (fixedLifeSpanStrategy.isSessionExpired(token)) {
// 30 days past, if user is in the mid of something try the timeout
// to extend his session
timeoutExpiryStrategy.isSessionExpired(token)
} else {
// 30 days not past yet Let him use the system
false
}
}
}
Few Usecases:
As you can see in ExtendedLifeSpanStrategy
, we can mix-match the implementations to tailor user experience. Let's say the buyer in our e-commerce website actively purchasing something and just before he makes payment, session expire due to 30 day lifespan — It would be frustrating. Three minutes added on each api call will alleviate the expiry.
If we want Seller account to logout on 3 minutes inactivity and a fixed timespan for Buyer, it can be achieved by picking strategy 'per role'.
val strategies = mapOf(
BUYER to fixedTimeSpan,
SELLER to timeoutStrategy
)
...
strategy[role].isSessionExpired(token)
We have a strategy and a tokenservice in place — let's move on to the final piece where we connect both.
👮 Request Manager
Before we move on to the logic, let's fix the 🐞 in user service. From now, it will return null
role for unknown/expired tokens. Still, if there is no token present, session will be identified as VISITOR.
fun identifyRole(token: String?): Roles? {
return if (token == null)
Roles.VISITOR
else
role[token]
}
In the request manager, wire the expiry strategy as you see fit. We'll use qualifiers to inject different strategies that we implemented in last section.
@Autowired
@Qualifier("timeout")
// @Qualifier("extended")
// @Qualifier("fixed_time")
private lateinit var tokenExpiryStrategy: SessionExpiryStrategy
// 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)
if (role == null) {
broadcastTokenWipe(token)
throw unauthorizedException()
}
// Handling users that need a session
if (role != Roles.VISITOR) {
val sessionToken = tokenService.peekToken(token)
if (sessionToken == null || tokenExpiryStrategy.isSessionExpired(sessionToken)) {
broadcastTokenWipe(token)
throw unauthorizedException()
}
// punch-in to the token service
tokenService.updateLastAccessTime(token!!)
}
request.setAttribute(
KEY_SESSION, DummySession(
role = role
)
)
}
Added inline comments for clarity, again a walkthrough here. If role is null, throw exception. If session token is null or expired for non-VISITORs throw exception. In both cases wipe the token off the system.
So, what do we throw? — 401 response.
private fun unauthorizedException() = HttpClientErrorException.create(
HttpStatus.UNAUTHORIZED,
"",
HttpHeaders.EMPTY,
ByteArray(0),
Charsets.UTF_8
)
If the session is valid, update the last access time in token service to support TimeoutStrategy
.
🐞 More on error handling
In case, the session is timeout, don't let the request proceed further to protect resources. i.e break the chain in request filter.
@Component
class DummyRequestFilter : OncePerRequestFilter() {
@Autowired
private lateinit var requestManager: DummyRequestManager
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
try {
// Feed the request to request manager for session preparation
requestManager.saveSession(request)
// Resume with request
filterChain.doFilter(request, response)
} catch (e: HttpClientErrorException) {
response.sendError(e.rawStatusCode, e.statusText)
}
}
}
In dummy request filter, once per request check whether the token is valid. If so, resume the chain, otherwise catch and propogate the 401 error code to the client.
🐛 That's it 🐛
🚀 Run it!
curl 'http://localhost:8080/graphql' \
-H 'x-auth-token: token-random-one' \
-H 'Content-Type: application/json' \
--data-raw '{"query":"mutation {addProduct}","variables":null}' \
--compressed
{"timestamp":"2021-06-01T18:28:55.325+00:00","status":401,"error":"Unauthorized","path":"/graphql"}
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"}}
📖 Resources
- Github repo
- GraphQL playground — http://localhost:8080/graphiql
Top comments (0)