feature/#30-group-authentication #31
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue