diff --git a/build.gradle.kts b/build.gradle.kts index d1502a1..7d857c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,7 +30,7 @@ dependencies { implementation(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}")) implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}") implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.11.3") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.4") implementation("javax.xml.bind:jaxb-api:2.3.0") implementation("io.jsonwebtoken:jjwt-api:0.11.2") implementation("io.jsonwebtoken:jjwt-impl:0.11.2") 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 2ded760..4c8e5e5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -3,8 +3,10 @@ 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.utils.buildJwt import dev.fyloz.colorrecipesexplorer.utils.parseJwt import io.jsonwebtoken.ExpiredJwtException @@ -28,6 +30,7 @@ class JwtAuthenticationFilter( private val securityProperties: CreSecurityProperties, private val updateUserLoginTime: (Long) -> Unit ) : UsernamePasswordAuthenticationFilter() { + private val objectMapper = jacksonObjectMapper() private var debugMode = false init { @@ -36,7 +39,7 @@ class JwtAuthenticationFilter( } override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java) + val loginRequest = objectMapper.readValue(request.inputStream, UserLoginRequest::class.java) return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)) } @@ -101,7 +104,7 @@ class JwtAuthorizationFilter( private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? { return try { - with(parseJwt(token.replace("Bearer", ""), securityProperties.jwtSecret)) { + with(parseJwt(token.replace("Bearer", ""), securityProperties.jwtSecret)) { getAuthenticationToken(this.subject) } } catch (_: ExpiredJwtException) { 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 8fbee34..6fceff4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt @@ -189,6 +189,18 @@ 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 +) + // ==== Exceptions ==== private const val USER_NOT_FOUND_EXCEPTION_TITLE = "User not found" private const val USER_ALREADY_EXISTS_EXCEPTION_TITLE = "User already exists" 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 b1df3ea..8211687 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt @@ -62,15 +62,7 @@ class UserServiceImpl( override fun idNotFoundException(id: Long) = userIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = userIdAlreadyExistsException(id) - override fun User.toOutput() = UserOutputDto( - this.id, - this.firstName, - this.lastName, - this.group, - this.flatPermissions, - this.permissions, - this.lastLoginTime - ) + override fun User.toOutput() = userOutputDto(this) 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 dbfb2df..c8b53b3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt @@ -1,26 +1,40 @@ 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 dev.fyloz.colorrecipesexplorer.model.account.User +import dev.fyloz.colorrecipesexplorer.model.account.userOutputDto import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jws 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.security.Keys import java.util.* import javax.crypto.SecretKey -data class Jwt( + +data class Jwt( val subject: String, val secret: String, val duration: Long? = null, val signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithm.HS512, - val claims: Map = mapOf() + val body: B? = null ) { val token: String by lazy { val builder = Jwts.builder() .signWith(keyFromSecret(secret)) .setSubject(subject) - .addClaims(mappedClaims) + .claim("payload", body) duration?.let { val expirationMs = System.currentTimeMillis() + it @@ -31,45 +45,95 @@ data class Jwt( builder.compact() } - - private val mappedClaims: Map by lazy { - claims - .filterValues { it != null } - .mapKeys { it.key.claim } - .mapValues { it.value.toString() } - } } -enum class JwtClaim(val claim: String) { +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, - claims = mapOf( - JwtClaim.GROUP_ID to this.group?.id, - JwtClaim.GROUP_NAME to this.group?.name - ) + 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. */ -fun parseJwt(jwt: String, secret: String) = +inline fun parseJwt(jwt: String, secret: String) = with( Jwts.parserBuilder() + .deserializeJsonWith(JacksonDeserializer(mapOf("payload" to B::class.java))) .setSigningKey(keyFromSecret(secret)) .build() .parseClaimsJws(jwt) ) { - Jwt(this.body.subject, secret) + val jwt = Jwt(this.body.subject, secret) + + val payload = this.body.get("payload", B::class.java) + jwt } /** Creates a base64 encoded [SecretKey] from the given [secret]. */ -private fun keyFromSecret(secret: String) = +fun keyFromSecret(secret: String) = 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) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt index 48bce7d..7fd9b53 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt @@ -19,7 +19,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -import org.springframework.security.core.userdetails.User as SpringUser @TestInstance(TestInstance.Lifecycle.PER_CLASS) class UserServiceTest : diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt index 0e055ad..8d73364 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt @@ -6,6 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.account.group import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService +import dev.fyloz.colorrecipesexplorer.service.users.GroupService import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test