#30 Use a JWS to store the group token id cookie
continuous-integration/drone/push Build is passing Details

This commit is contained in:
FyloZ 2022-04-28 23:38:33 -04:00
parent 64349b25e9
commit 531fa252d2
Signed by: william
GPG Key ID: 835378AE9AF4AE97
14 changed files with 72 additions and 49 deletions

View File

@ -1,6 +1,7 @@
package dev.fyloz.colorrecipesexplorer
object Constants {
val BEARER_PREFIX = "Bearer"
var DEBUG_MODE = false // Not really a constant, but should never change after the app startup
object ControllerPaths {

View File

@ -4,7 +4,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import java.util.*
class GroupAuthenticationToken(val id: String) : AbstractAuthenticationToken(null) {
class GroupAuthenticationToken(val id: UUID) : AbstractAuthenticationToken(null) {
override fun getPrincipal() = id
// There is no credential needed to log in with a group token, just use the group token id

View File

@ -12,9 +12,7 @@ import java.util.*
class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLogic) : AuthenticationProvider {
override fun authenticate(authentication: Authentication): Authentication {
val groupAuthenticationToken = authentication as GroupAuthenticationToken
val groupTokenId = parseGroupTokenId(groupAuthenticationToken.id)
val groupToken = retrieveGroupToken(groupTokenId)
val groupToken = retrieveGroupToken(groupAuthenticationToken.id)
val userDetails =
UserDetails(groupToken.id.toString(), groupToken.name, "", groupToken.group, groupToken.group.permissions)
@ -24,12 +22,6 @@ class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLo
override fun supports(authentication: Class<*>) =
authentication.isAssignableFrom(GroupAuthenticationToken::class.java)
private fun parseGroupTokenId(id: String) = try {
UUID.fromString(id)
} catch (_: IllegalArgumentException) {
throw BadCredentialsException("Group token id must be a valid UUID")
}
private fun retrieveGroupToken(id: UUID) = try {
groupTokenLogic.getById(id)
} catch (_: NotFoundException) {

View File

@ -107,7 +107,8 @@ abstract class BaseSecurityConfig(
.and()
.authorizeRequests()
.antMatchers("/api/config/**").permitAll() // Allow access to logo and icon
.antMatchers("/api/account/login/**").permitAll() // Allow access to login
.antMatchers("/api/account/login").permitAll() // Allow access to login
.antMatchers("/api/account/login/group").permitAll() // Allow access to group login
.antMatchers("**").fullyAuthenticated()
if (Constants.DEBUG_MODE) {

View File

@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.config.security.GroupAuthenticationToken
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
import dev.fyloz.colorrecipesexplorer.utils.parseBearer
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.core.Authentication
@ -20,7 +21,9 @@ class GroupTokenAuthenticationFilter(
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
val groupTokenCookie = getGroupTokenCookie(request)
?: throw BadCredentialsException("Required group token cookie was not present")
val groupTokenId = groupTokenCookie.value
val jwt = parseBearer(groupTokenCookie.value)
val groupTokenId = jwtLogic.parseGroupTokenIdJwt(jwt)
logger.debug("Login attempt for group token $groupTokenId")
return authManager.authenticate(GroupAuthenticationToken(groupTokenId))

View File

@ -18,7 +18,7 @@ import javax.servlet.http.HttpServletResponse
abstract class JwtAuthenticationFilter(
filterProcessesUrl: String,
private val securityProperties: CreSecurityProperties,
private val jwtLogic: JwtLogic
protected val jwtLogic: JwtLogic
) :
AbstractAuthenticationProcessingFilter(
AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString())
@ -32,7 +32,7 @@ abstract class JwtAuthenticationFilter(
auth: Authentication
) {
val userDetails = auth.principal as UserDetails
val token = jwtLogic.buildJwt(userDetails)
val token = jwtLogic.buildUserJwt(userDetails)
addAuthorizationCookie(response, token)
addResponseBody(userDetails, response)
@ -43,7 +43,7 @@ abstract class JwtAuthenticationFilter(
protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails)
private fun addAuthorizationCookie(response: HttpServletResponse, token: String) {
response.addCookie(Constants.CookieNames.AUTHORIZATION, BEARER_TOKEN_PREFIX + token) {
response.addCookie(Constants.CookieNames.AUTHORIZATION, Constants.BEARER_PREFIX + token) {
httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY
sameSite = AUTHORIZATION_COOKIE_SAME_SITE
secure = !Constants.DEBUG_MODE
@ -72,7 +72,5 @@ abstract class JwtAuthenticationFilter(
private const val AUTHORIZATION_COOKIE_HTTP_ONLY = true
private const val AUTHORIZATION_COOKIE_SAME_SITE = true
private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH
const val BEARER_TOKEN_PREFIX = "Bearer"
}
}

View File

@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.config.security.filters
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
import dev.fyloz.colorrecipesexplorer.logic.account.UserJwt
import dev.fyloz.colorrecipesexplorer.utils.parseBearer
import io.jsonwebtoken.ExpiredJwtException
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
@ -39,12 +40,12 @@ class JwtAuthorizationFilter(
// The authorization token is valid if it starts with "Bearer"
private fun isJwtValid(authorizationToken: String) =
authorizationToken.startsWith(JwtAuthenticationFilter.BEARER_TOKEN_PREFIX)
authorizationToken.startsWith(Constants.BEARER_PREFIX)
private fun getAuthentication(authorizationToken: String): Authentication? {
return try {
val jwt = authorizationToken.replace(JwtAuthenticationFilter.BEARER_TOKEN_PREFIX, "").trim()
val user = jwtLogic.parseJwt(jwt)
val jwt = parseBearer(authorizationToken)
val user = jwtLogic.parseUserJwt(jwt)
getAuthentication(user)
} catch (_: ExpiredJwtException) {

View File

@ -19,11 +19,17 @@ import java.util.*
const val jwtClaimUser = "user"
interface JwtLogic {
/** Build a JWT token for the given [userDetails]. */
fun buildJwt(userDetails: UserDetails): String
/** Build a JWT for the given [userDetails]. */
fun buildUserJwt(userDetails: UserDetails): String
/** Parses a user information from the given [jwt] token. */
fun parseJwt(jwt: String): UserJwt
/** Build a JWT for the given [groupTokenId]. */
fun buildGroupTokenIdJwt(groupTokenId: UUID): String
/** Parses a user information from the given [jwt]. */
fun parseUserJwt(jwt: String): UserJwt
/** Parses a group token id from the given [jwt]. */
fun parseGroupTokenIdJwt(jwt: String): UUID
}
@Service
@ -35,11 +41,11 @@ class DefaultJwtLogic(
securityProperties.jwtSecret.base64encode()
}
private val jwtBuilder by lazy {
// Must be a new instance every time, or data from the last token will still be there
private val jwtBuilder get() =
Jwts.builder()
.serializeToJsonWith(JacksonSerializer<Map<String, *>>(objectMapper))
.signWith(secretKey)
}
private val jwtParser by lazy {
Jwts.parserBuilder()
@ -48,14 +54,19 @@ class DefaultJwtLogic(
.build()
}
override fun buildJwt(userDetails: UserDetails): String =
override fun buildUserJwt(userDetails: UserDetails): String =
jwtBuilder
.setSubject(userDetails.id)
.setExpiration(getCurrentExpirationDate())
.claim(JWT_CLAIM_PERMISSIONS, objectMapper.writeValueAsString(userDetails.permissions))
.compact()
override fun parseJwt(jwt: String): UserJwt {
override fun buildGroupTokenIdJwt(groupTokenId: UUID): String =
jwtBuilder
.setSubject(groupTokenId.toString())
.compact()
override fun parseUserJwt(jwt: String): UserJwt {
val parsedJwt = jwtParser.parseClaimsJws(jwt)
val serializedPermissions = parsedJwt.body.get(JWT_CLAIM_PERMISSIONS, String::class.java)
@ -68,6 +79,11 @@ class DefaultJwtLogic(
return UserJwt(parsedJwt.body.subject, authorities)
}
override fun parseGroupTokenIdJwt(jwt: String): UUID {
val uuid = jwtParser.parseClaimsJws(jwt).body.subject
return UUID.fromString(uuid)
}
private fun getCurrentExpirationDate(): Date =
Instant.now()
.plusSeconds(securityProperties.jwtDuration)

View File

@ -15,7 +15,7 @@ import org.springframework.stereotype.Service
interface UserDetailsLogic : SpringUserDetailsService {
/** Loads an [User] for the given [id]. */
fun loadUserById(id: Long, isDefaultGroupUser: Boolean = true): UserDetails
fun loadUserById(id: Long): UserDetails
}
@Service
@ -25,13 +25,13 @@ class DefaultUserDetailsLogic(
) : UserDetailsLogic {
override fun loadUserByUsername(username: String): UserDetails {
try {
return loadUserById(username.toLong(), false)
return loadUserById(username.toLong())
} catch (ex: NotFoundException) {
throw UsernameNotFoundException(username)
}
}
override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails {
override fun loadUserById(id: Long): UserDetails {
val user = userLogic.getById(id, isSystemUser = true)
return UserDetails(user)
}
@ -65,10 +65,10 @@ class EmergencyUserDetailsLogic(
}
override fun loadUserByUsername(username: String): SpringUserDetails {
return loadUserById(username.toLong(), false)
return loadUserById(username.toLong())
}
override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails {
override fun loadUserById(id: Long): UserDetails {
val user = users.firstOrNull { it.id == id }
?: throw UsernameNotFoundException(id.toString())

View File

@ -57,7 +57,7 @@ class DefaultUserLogic(
override fun getById(id: Long) = getById(id, false)
override fun getById(id: Long, isSystemUser: Boolean) =
service.getById(id, !isSystemUser) ?: throw notFoundException(value = id)
service.getById(id, isSystemUser) ?: throw notFoundException(value = id)
override fun save(dto: UserSaveDto) = save(
UserDto(
@ -79,7 +79,7 @@ class DefaultUserLogic(
}
override fun update(dto: UserUpdateDto): UserDto {
val user = getById(dto.id, isSystemUser = false)
val user = getById(dto.id)
return update(
user.copy(
@ -97,7 +97,7 @@ class DefaultUserLogic(
return super.update(dto)
}
override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id)) {
override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id, true)) {
update(this.copy(lastLoginTime = time))
}

View File

@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic
import dev.fyloz.colorrecipesexplorer.rest.created
import dev.fyloz.colorrecipesexplorer.rest.noContent

View File

@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeAdmin
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto
import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
import dev.fyloz.colorrecipesexplorer.rest.created
import dev.fyloz.colorrecipesexplorer.rest.noContent
import dev.fyloz.colorrecipesexplorer.rest.ok
@ -18,7 +19,10 @@ import javax.validation.Valid
@RequestMapping(Constants.ControllerPaths.GROUP_TOKEN)
@PreAuthorizeAdmin
@Profile("!emergency")
class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) {
class GroupTokenController(
private val groupTokenLogic: GroupTokenLogic,
private val jwtLogic: JwtLogic
) {
@GetMapping
fun getAll() = ok(groupTokenLogic.getAll())
@ -46,11 +50,14 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) {
}
private fun addGroupTokenCookie(response: HttpServletResponse, groupToken: GroupTokenDto) {
response.addCookie(Constants.CookieNames.GROUP_TOKEN, groupToken.id.toString()) {
val jwt = jwtLogic.buildGroupTokenIdJwt(groupToken.id)
val bearer = Constants.BEARER_PREFIX + jwt
response.addCookie(Constants.CookieNames.GROUP_TOKEN, bearer) {
httpOnly = GROUP_TOKEN_COOKIE_HTTP_ONLY
sameSite = GROUP_TOKEN_COOKIE_SAME_SITE
secure = !Constants.DEBUG_MODE
maxAge = GROUP_TOKEN_COOKIE_MAX_AGE
maxAge = null // This cookie should never expire
path = GROUP_TOKEN_COOKIE_PATH
}
}
@ -58,7 +65,6 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) {
companion object {
private const val GROUP_TOKEN_COOKIE_HTTP_ONLY = true
private const val GROUP_TOKEN_COOKIE_SAME_SITE = true
private const val GROUP_TOKEN_COOKIE_MAX_AGE = Long.MAX_VALUE // This cookie should never expire
private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.GROUP_LOGIN
}
}

View File

@ -15,7 +15,7 @@ data class CookieBuilderOptions(
var secure: Boolean = DEFAULT_SECURE,
/** Cookie's maximum age in seconds. */
var maxAge: Long = DEFAULT_MAX_AGE,
var maxAge: Long? = DEFAULT_MAX_AGE,
/** The path for which the cookie will be sent. */
var path: String = DEFAULT_PATH
@ -51,7 +51,8 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild
}
}
fun addOption(option: CookieBuilderOption, value: Any) {
fun addOption(option: CookieBuilderOption, value: Any?) {
if (value == null) return
cookie.append("${option.optionName}=$value;")
}
@ -62,4 +63,7 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild
addOption(CookieBuilderOption.PATH, options.path)
return cookie.toString()
}
}
fun parseBearer(source: String) =
source.replace(Constants.BEARER_PREFIX, "").trim()

View File

@ -52,7 +52,7 @@ class DefaultJwtLogicTest {
fun buildJwt_userDetails_normalBehavior_returnsJwtStringWithValidUser() {
val userDetails = UserDetails(user)
val builtJwt = jwtService.buildJwt(userDetails)
val builtJwt = jwtService.buildUserJwt(userDetails)
withParsedUserOutputDto(builtJwt) { parsedUser ->
assertEquals(user, parsedUser)
@ -61,7 +61,7 @@ class DefaultJwtLogicTest {
@Test
fun buildJwt_user_normalBehavior_returnsJwtStringWithValidUser() {
val builtJwt = jwtService.buildJwt(user)
val builtJwt = jwtService.buildUserJwt(user)
withParsedUserOutputDto(builtJwt) { parsedUser ->
assertEquals(user, parsedUser)
@ -70,7 +70,7 @@ class DefaultJwtLogicTest {
@Test
fun buildJwt_user_normalBehavior_returnsJwtStringWithValidSubject() {
val builtJwt = jwtService.buildJwt(user)
val builtJwt = jwtService.buildUserJwt(user)
val jwtSubject = jwtParser.parseClaimsJws(builtJwt).body.subject
assertEquals(user.id.toString(), jwtSubject)
@ -80,7 +80,7 @@ class DefaultJwtLogicTest {
fun buildJwt_user_returnsJwtWithValidExpirationDate() {
val jwtExpectedExpirationDate = Instant.now().plusSeconds(securityProperties.jwtDuration)
val builtJwt = jwtService.buildJwt(user)
val builtJwt = jwtService.buildUserJwt(user)
val jwtExpiration = jwtParser.parseClaimsJws(builtJwt)
.body.expiration.toInstant()
@ -92,8 +92,8 @@ class DefaultJwtLogicTest {
@Test
fun parseJwt_normalBehavior_returnsExpectedUser() {
val jwt = jwtService.buildJwt(user)
val parsedUser = jwtService.parseJwt(jwt)
val jwt = jwtService.buildUserJwt(user)
val parsedUser = jwtService.parseUserJwt(jwt)
assertEquals(user, parsedUser)
}