diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index ff032b5..6286baa 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -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 { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt index 9bb42a3..6368356 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt @@ -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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt index 2001f35..d865f45 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -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) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index de1d0fe..a9fe540 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -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) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt index ede8678..d2c7601 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -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)) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt index 02b9aa9..08fe21c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -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" } } \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt index d7f8cb5..52f5a43 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -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) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 5938e98..b2b6c38 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -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>(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) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt index 972dee1..b62a52c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt @@ -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()) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index 6ca5ad6..6725b2c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -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)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt index 3626696..ce394b2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt @@ -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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index 75e69f3..b2adbd1 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -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 } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt index 60db2de..c83f0cb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt @@ -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() -} \ No newline at end of file +} + +fun parseBearer(source: String) = + source.replace(Constants.BEARER_PREFIX, "").trim() \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt index 1e91082..08d784c 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt @@ -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) }