Move user infos to JWT tokens #19
|
@ -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<UserOutputDto>(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())
|
||||
}
|
||||
|
|
|
@ -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<Permission>.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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<B>(
|
||||
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<Map<String, *>>(objectMapper)
|
||||
private val deserializer = JacksonDeserializer<Map<String, *>>(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<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. */
|
||||
inline fun <reified B> 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<B>(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 <reified T> Jws<Claims>.getClaim(claimType: ClaimType) =
|
||||
this.body.get(claimType.key, T::class.java)
|
||||
|
|
Loading…
Reference in New Issue