Move user infos to JWT tokens #19
|
@ -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")
|
||||
|
|
|
@ -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<UserOutputDto>(token.replace("Bearer", ""), securityProperties.jwtSecret)) {
|
||||
getAuthenticationToken(this.subject)
|
||||
}
|
||||
} catch (_: ExpiredJwtException) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<B>(
|
||||
val subject: String,
|
||||
val secret: String,
|
||||
val duration: Long? = null,
|
||||
val signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithm.HS512,
|
||||
val claims: Map<JwtClaim, Any?> = 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<String, String> 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<T>(
|
||||
// val claimTypeMap: Map<String, Class<*>>
|
||||
//) : Deserializer<T> {
|
||||
// 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<String, Class<*>>
|
||||
//) : 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<TreeNode>().traverse(parser.codec).readValueAs(type)
|
||||
// }
|
||||
// // otherwise default to super
|
||||
// return super.deserialize(parser, context)
|
||||
// }
|
||||
//}
|
||||
|
||||
class CustomDeserializer<T>(map: Map<String, Class<*>>) {
|
||||
private val objectMapper: ObjectMapper = jacksonObjectMapper()
|
||||
private val returnType: Class<T> = Object::class.java as Class<T>
|
||||
}
|
||||
|
||||
/** Parses the given [jwt] string. */
|
||||
fun parseJwt(jwt: String, secret: String) =
|
||||
inline fun <reified B> 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<B>(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 <reified T> Jws<Claims>.getClaim(claimType: ClaimType) =
|
||||
this.body.get(claimType.key, T::class.java)
|
||||
|
|
|
@ -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 :
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue