diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt index 4c8e5e5..d4a36bf 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -3,12 +3,9 @@ package dev.fyloz.colorrecipesexplorer.config.security import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.model.account.UserDetails -import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest -import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto +import dev.fyloz.colorrecipesexplorer.model.account.* import dev.fyloz.colorrecipesexplorer.utils.buildJwt -import dev.fyloz.colorrecipesexplorer.utils.parseJwt +import dev.fyloz.colorrecipesexplorer.utils.parseJwtUser import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -30,7 +27,6 @@ class JwtAuthenticationFilter( private val securityProperties: CreSecurityProperties, private val updateUserLoginTime: (Long) -> Unit ) : UsernamePasswordAuthenticationFilter() { - private val objectMapper = jacksonObjectMapper() private var debugMode = false init { @@ -39,7 +35,7 @@ class JwtAuthenticationFilter( } override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginRequest = objectMapper.readValue(request.inputStream, UserLoginRequest::class.java) + val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java) return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)) } @@ -50,8 +46,7 @@ class JwtAuthenticationFilter( auth: Authentication ) { val userDetails = (auth.principal as UserDetails) - val token = - userDetails.user.buildJwt(securityProperties.jwtSecret, duration = securityProperties.jwtDuration).token + val token = userDetails.user.buildJwt(securityProperties.jwtSecret, securityProperties.jwtDuration) var bearerCookie = "$authorizationCookieName=Bearer$token; Max-Age=${securityProperties.jwtDuration / 1000}; HttpOnly; SameSite=strict" @@ -104,18 +99,23 @@ class JwtAuthorizationFilter( private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? { return try { - with(parseJwt(token.replace("Bearer", ""), securityProperties.jwtSecret)) { - getAuthenticationToken(this.subject) - } + val user = parseJwtUser(token.replace("Bearer", ""), securityProperties.jwtSecret) + getAuthenticationToken(user) } catch (_: ExpiredJwtException) { null } } - private fun getAuthenticationToken(userId: String): UsernamePasswordAuthenticationToken? = try { - val userDetails = loadUserById(userId.toLong()) + private fun getAuthenticationToken(user: UserOutputDto) = + UsernamePasswordAuthenticationToken(user.id, null, user.permissions.toAuthorities()) + + private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try { + val userDetails = loadUserById(userId) UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities) } catch (_: NotFoundException) { null } + + private fun getAuthenticationToken(userId: String) = + getAuthenticationToken(userId.toLong()) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt index 6fceff4..ac6f5d6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt @@ -7,7 +7,6 @@ import dev.fyloz.colorrecipesexplorer.model.EntityDto import dev.fyloz.colorrecipesexplorer.model.Model import org.hibernate.annotations.Fetch import org.hibernate.annotations.FetchMode -import org.springframework.security.core.GrantedAuthority import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import java.time.LocalDateTime @@ -111,9 +110,7 @@ data class UserLoginRequest(val id: Long, val password: String) data class UserDetails(val user: User) : SpringUserDetails { override fun getPassword() = user.password override fun getUsername() = user.id.toString() - - override fun getAuthorities() = - user.flatPermissions.map { it.toAuthority() }.toMutableSet() + override fun getAuthorities() = user.flatPermissions.toAuthorities() override fun isAccountNonExpired() = true override fun isAccountNonLocked() = true @@ -189,17 +186,20 @@ fun userUpdateDto( op: UserUpdateDto.() -> Unit = {} ) = UserUpdateDto(id, firstName, lastName, groupId, permissions).apply(op) -fun userOutputDto( - user: User -) = UserOutputDto( - user.id, - user.firstName, - user.lastName, - user.group, - user.flatPermissions, - user.permissions, - user.lastLoginTime -) +// ==== Extensions ==== +fun Set.toAuthorities() = + this.map { it.toAuthority() }.toMutableSet() + +fun User.toOutputDto() = + UserOutputDto( + this.id, + this.firstName, + this.lastName, + this.group, + this.flatPermissions, + this.permissions, + this.lastLoginTime + ) // ==== Exceptions ==== private const val USER_NOT_FOUND_EXCEPTION_TITLE = "User not found" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt index 8211687..04add2b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt @@ -62,7 +62,7 @@ class UserServiceImpl( override fun idNotFoundException(id: Long) = userIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = userIdAlreadyExistsException(id) - override fun User.toOutput() = userOutputDto(this) + override fun User.toOutput() = this.toOutputDto() override fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean = repository.existsByFirstNameAndLastName(firstName, lastName) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt index c8b53b3..280e7e0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt @@ -1,139 +1,56 @@ package dev.fyloz.colorrecipesexplorer.utils -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.TreeNode -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonMapperBuilder +import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.model.account.userOutputDto -import io.jsonwebtoken.Claims -import io.jsonwebtoken.Jws +import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto +import dev.fyloz.colorrecipesexplorer.model.account.toOutputDto import io.jsonwebtoken.Jwts -import io.jsonwebtoken.SignatureAlgorithm -import io.jsonwebtoken.io.DeserializationException -import io.jsonwebtoken.io.Deserializer import io.jsonwebtoken.io.Encoders -import io.jsonwebtoken.io.IOException import io.jsonwebtoken.jackson.io.JacksonDeserializer +import io.jsonwebtoken.jackson.io.JacksonSerializer import io.jsonwebtoken.security.Keys import java.util.* import javax.crypto.SecretKey +private const val userClaimName = "user" -data class Jwt( - val subject: String, - val secret: String, - val duration: Long? = null, - val signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithm.HS512, - val body: B? = null -) { - val token: String by lazy { - val builder = Jwts.builder() - .signWith(keyFromSecret(secret)) - .setSubject(subject) - .claim("payload", body) +private val objectMapper = jacksonMapperBuilder() + .apply { addModule(JavaTimeModule()) } + .build() +private val serializer = JacksonSerializer>(objectMapper) +private val deserializer = JacksonDeserializer>(objectMapper) - duration?.let { - val expirationMs = System.currentTimeMillis() + it - val expirationDate = Date(expirationMs) +/** Build a JWT token for the given [User]. */ +fun User.buildJwt(secret: String, duration: Long): String { + val expirationDate = Date(System.currentTimeMillis() + duration) + val serializedUser = objectMapper.writeValueAsString(this.toOutputDto()) - builder.setExpiration(expirationDate) - } - - builder.compact() - } + return Jwts.builder() + .serializeToJsonWith(serializer) + .signWith(keyFromSecret(secret)) + .setSubject(this.id.toString()) + .setExpiration(expirationDate) + .claim(userClaimName, serializedUser) + .compact() } -enum class ClaimType(val key: String) { - GROUP_ID("groupId"), - GROUP_NAME("groupName") -} - -data class UserJwtBody( - val groupId: Long?, - val groupName: String? -) - -/** Build a [Jwt] for the given [User]. */ -fun User.buildJwt(secret: String, duration: Long?) = - Jwt( - subject = this.id.toString(), - secret, - duration, - body = userOutputDto(this) - ) - -//class JacksonDeserializer( -// val claimTypeMap: Map> -//) : Deserializer { -// val objectMapper: ObjectMapper -// -// init { -// objectMapper = jacksonObjectMapper() -// -// val module = SimpleModule() -// module.addDeserializer(Any::class.java, MappedTypeDeserializer(Collections.unmodifiableMap(claimTypeMap))) -// objectMapper.registerModule(module) -// } -// -// override fun deserialize(bytes: ByteArray?): T { -// return try { -// readValue(bytes) -// } catch (e: IOException) { -// val msg = -// "Unable to deserialize bytes into a " + returnType.getName().toString() + " instance: " + e.getMessage() -// throw DeserializationException(msg, e) -// } -// } -// -// protected fun readValue(bytes: ByteArray?): T { -// return objectMapper.readValue(bytes, returnType) -// } -//} -// -//private class MappedTypeDeserializer( -// private val claimTypeMap: Map> -//) : UntypedObjectDeserializer(null, null) { -// override fun deserialize(parser: JsonParser, context: DeserializationContext): Any { -// val name: String = parser.currentName() -// if (claimTypeMap.containsKey(name)) { -// val type = claimTypeMap[name]!! -// return parser.readValueAsTree().traverse(parser.codec).readValueAs(type) -// } -// // otherwise default to super -// return super.deserialize(parser, context) -// } -//} - -class CustomDeserializer(map: Map>) { - private val objectMapper: ObjectMapper = jacksonObjectMapper() - private val returnType: Class = Object::class.java as Class -} - -/** Parses the given [jwt] string. */ -inline fun parseJwt(jwt: String, secret: String) = +/** Parses the user of the given [jwt]. */ +fun parseJwtUser(jwt: String, secret: String): UserOutputDto = with( Jwts.parserBuilder() - .deserializeJsonWith(JacksonDeserializer(mapOf("payload" to B::class.java))) + .deserializeJsonWith(deserializer) .setSigningKey(keyFromSecret(secret)) .build() .parseClaimsJws(jwt) + .body.get(userClaimName, String::class.java) ) { - val jwt = Jwt(this.body.subject, secret) - - val payload = this.body.get("payload", B::class.java) - jwt + objectMapper.readValue(this) } /** Creates a base64 encoded [SecretKey] from the given [secret]. */ -fun keyFromSecret(secret: String) = +private fun keyFromSecret(secret: String): SecretKey = with(Encoders.BASE64.encode(secret.toByteArray())) { Keys.hmacShaKeyFor(this.toByteArray()) } - -/** Gets the claim with the given [claimType] in a [Jws]. */ -private inline fun Jws.getClaim(claimType: ClaimType) = - this.body.get(claimType.key, T::class.java)