Move user infos to JWT tokens #19
|
@ -22,7 +22,7 @@ repositories {
|
|||
mavenCentral()
|
||||
|
||||
maven {
|
||||
url = uri("https://git.fyloz.dev/api/v4/projects/40/packages/maven")
|
||||
url = uri("https://archiva.fyloz.dev/repository/internal")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,7 +37,7 @@ dependencies {
|
|||
implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")
|
||||
implementation("org.apache.poi:poi-ooxml:4.1.0")
|
||||
implementation("org.apache.pdfbox:pdfbox:2.0.4")
|
||||
implementation("dev.fyloz.colorrecipesexplorer:database-manager:5.2")
|
||||
implementation("dev.fyloz.colorrecipesexplorer:database-manager:5.2.1")
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa:${springBootVersion}")
|
||||
implementation("org.springframework.boot:spring-boot-starter-jdbc:${springBootVersion}")
|
||||
|
|
|
@ -3,9 +3,12 @@ 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.*
|
||||
import dev.fyloz.colorrecipesexplorer.utils.buildJwt
|
||||
import dev.fyloz.colorrecipesexplorer.utils.parseJwtUser
|
||||
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.toAuthorities
|
||||
import dev.fyloz.colorrecipesexplorer.service.users.JwtService
|
||||
import dev.fyloz.colorrecipesexplorer.utils.addCookie
|
||||
import io.jsonwebtoken.ExpiredJwtException
|
||||
import org.springframework.security.authentication.AuthenticationManager
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
|
@ -20,10 +23,11 @@ import javax.servlet.http.HttpServletResponse
|
|||
|
||||
const val authorizationCookieName = "Authorization"
|
||||
const val defaultGroupCookieName = "Default-Group"
|
||||
val blacklistedJwtTokens = mutableListOf<String>()
|
||||
val blacklistedJwtTokens = mutableListOf<String>() // Not working, move to a cache or something
|
||||
|
||||
class JwtAuthenticationFilter(
|
||||
private val authManager: AuthenticationManager,
|
||||
private val jwtService: JwtService,
|
||||
private val securityProperties: CreSecurityProperties,
|
||||
private val updateUserLoginTime: (Long) -> Unit
|
||||
) : UsernamePasswordAuthenticationFilter() {
|
||||
|
@ -45,24 +49,23 @@ class JwtAuthenticationFilter(
|
|||
chain: FilterChain,
|
||||
auth: Authentication
|
||||
) {
|
||||
val userDetails = (auth.principal as UserDetails)
|
||||
val token = userDetails.user.buildJwt(securityProperties.jwtSecret, securityProperties.jwtDuration)
|
||||
val userDetails = auth.principal as UserDetails
|
||||
val token = jwtService.buildJwt(userDetails)
|
||||
|
||||
var bearerCookie =
|
||||
"$authorizationCookieName=Bearer$token; Max-Age=${securityProperties.jwtDuration / 1000}; HttpOnly; SameSite=strict"
|
||||
if (!debugMode) bearerCookie += "; Secure;"
|
||||
response.addHeader(
|
||||
"Set-Cookie",
|
||||
bearerCookie
|
||||
)
|
||||
response.addHeader(authorizationCookieName, "Bearer $token")
|
||||
response.addCookie(authorizationCookieName, "Bearer$token") {
|
||||
httpOnly = true
|
||||
sameSite = true
|
||||
secure = !debugMode
|
||||
maxAge = securityProperties.jwtDuration / 1000
|
||||
}
|
||||
|
||||
updateUserLoginTime(userDetails.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
class JwtAuthorizationFilter(
|
||||
private val securityProperties: CreSecurityProperties,
|
||||
private val jwtService: JwtService,
|
||||
authenticationManager: AuthenticationManager,
|
||||
private val loadUserById: (Long) -> UserDetails
|
||||
) : BasicAuthenticationFilter(authenticationManager) {
|
||||
|
@ -99,7 +102,7 @@ class JwtAuthorizationFilter(
|
|||
|
||||
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
|
||||
return try {
|
||||
val user = parseJwtUser(token.replace("Bearer", ""), securityProperties.jwtSecret)
|
||||
val user = jwtService.parseJwt(token.replace("Bearer", ""))
|
||||
getAuthenticationToken(user)
|
||||
} catch (_: ExpiredJwtException) {
|
||||
null
|
||||
|
|
|
@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
|||
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
||||
import dev.fyloz.colorrecipesexplorer.service.users.JwtService
|
||||
import dev.fyloz.colorrecipesexplorer.service.users.UserDetailsService
|
||||
import dev.fyloz.colorrecipesexplorer.service.users.UserService
|
||||
import org.slf4j.Logger
|
||||
|
@ -41,6 +42,7 @@ class SecurityConfig(
|
|||
private val securityProperties: CreSecurityProperties,
|
||||
@Lazy private val userDetailsService: UserDetailsService,
|
||||
@Lazy private val userService: UserService,
|
||||
private val jwtService: JwtService,
|
||||
private val environment: Environment,
|
||||
private val logger: Logger
|
||||
) : WebSecurityConfigurerAdapter() {
|
||||
|
@ -86,12 +88,12 @@ class SecurityConfig(
|
|||
.and()
|
||||
.csrf().disable()
|
||||
.addFilter(
|
||||
JwtAuthenticationFilter(authenticationManager(), securityProperties) {
|
||||
JwtAuthenticationFilter(authenticationManager(), jwtService, securityProperties) {
|
||||
userService.updateLastLoginTime(it)
|
||||
}
|
||||
)
|
||||
.addFilter(
|
||||
JwtAuthorizationFilter(securityProperties, authenticationManager()) {
|
||||
JwtAuthorizationFilter(jwtService, authenticationManager()) {
|
||||
userDetailsService.loadUserById(it, false)
|
||||
}
|
||||
)
|
||||
|
@ -117,6 +119,7 @@ class SecurityConfig(
|
|||
class EmergencySecurityConfig(
|
||||
private val securityProperties: CreSecurityProperties,
|
||||
private val userDetailsService: UserDetailsService,
|
||||
private val jwtService: JwtService,
|
||||
private val environment: Environment
|
||||
) : WebSecurityConfigurerAdapter() {
|
||||
init {
|
||||
|
@ -143,10 +146,10 @@ class EmergencySecurityConfig(
|
|||
.and()
|
||||
.csrf().disable()
|
||||
.addFilter(
|
||||
JwtAuthenticationFilter(authenticationManager(), securityProperties) { }
|
||||
JwtAuthenticationFilter(authenticationManager(), jwtService, securityProperties) { }
|
||||
)
|
||||
.addFilter(
|
||||
JwtAuthorizationFilter(securityProperties, authenticationManager()) {
|
||||
JwtAuthorizationFilter(jwtService, authenticationManager()) {
|
||||
userDetailsService.loadUserById(it, false)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
package dev.fyloz.colorrecipesexplorer.service.users
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.toOutputDto
|
||||
import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.io.Encoders
|
||||
import io.jsonwebtoken.jackson.io.JacksonDeserializer
|
||||
import io.jsonwebtoken.jackson.io.JacksonSerializer
|
||||
import io.jsonwebtoken.security.Keys
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
const val jwtClaimUser = "user"
|
||||
|
||||
interface JwtService {
|
||||
/** Build a JWT token for the given [userDetails]. */
|
||||
fun buildJwt(userDetails: UserDetails): String
|
||||
|
||||
/** Build a JWT token for the given [user]. */
|
||||
fun buildJwt(user: User): String
|
||||
|
||||
/** Parses a user from the given [jwt] token. */
|
||||
fun parseJwt(jwt: String): UserOutputDto
|
||||
}
|
||||
|
||||
@Service
|
||||
class JwtServiceImpl(
|
||||
val objectMapper: ObjectMapper,
|
||||
val securityProperties: CreSecurityProperties
|
||||
) : JwtService {
|
||||
private val secretKey by lazy {
|
||||
with(Encoders.BASE64.encode(securityProperties.jwtSecret.toByteArray())) {
|
||||
Keys.hmacShaKeyFor(this.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private val jwtBuilder by lazy {
|
||||
Jwts.builder()
|
||||
.serializeToJsonWith(JacksonSerializer<Map<String, *>>(objectMapper))
|
||||
.signWith(secretKey)
|
||||
}
|
||||
|
||||
private val jwtParser by lazy {
|
||||
Jwts.parserBuilder()
|
||||
.deserializeJsonWith(JacksonDeserializer<Map<String, *>>(objectMapper))
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun buildJwt(userDetails: UserDetails) =
|
||||
buildJwt(userDetails.user)
|
||||
|
||||
override fun buildJwt(user: User): String =
|
||||
jwtBuilder
|
||||
.setSubject(user.id.toString())
|
||||
.setExpiration(getCurrentExpirationDate())
|
||||
.claim(jwtClaimUser, user.serialize())
|
||||
.compact()
|
||||
|
||||
override fun parseJwt(jwt: String): UserOutputDto =
|
||||
with(
|
||||
jwtParser.parseClaimsJws(jwt)
|
||||
.body.get(jwtClaimUser, String::class.java)
|
||||
) {
|
||||
objectMapper.readValue(this)
|
||||
}
|
||||
|
||||
private fun getCurrentExpirationDate(): Date =
|
||||
Date(System.currentTimeMillis() + securityProperties.jwtDuration)
|
||||
|
||||
private fun User.serialize(): String =
|
||||
objectMapper.writeValueAsString(this.toOutputDto())
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package dev.fyloz.colorrecipesexplorer.utils
|
||||
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
private const val defaultCookieMaxAge = 3600L
|
||||
private const val defaultCookieHttpOnly = true
|
||||
private const val defaultCookieSameSite = true
|
||||
private const val defaultCookieSecure = true
|
||||
|
||||
data class CookieOptions(
|
||||
/** HTTP Only cookies cannot be access by Javascript clients. */
|
||||
var httpOnly: Boolean = defaultCookieHttpOnly,
|
||||
|
||||
/** SameSite cookies are only sent in requests to their origin location. */
|
||||
var sameSite: Boolean = defaultCookieSameSite,
|
||||
|
||||
/** Secure cookies are only sent in HTTPS requests. */
|
||||
var secure: Boolean = defaultCookieSecure,
|
||||
|
||||
/** Cookie's maximum age in seconds. */
|
||||
var maxAge: Long = defaultCookieMaxAge
|
||||
)
|
||||
|
||||
private enum class CookieOption(val optionName: String) {
|
||||
HTTP_ONLY("HttpOnly"),
|
||||
SAME_SITE("SameSite"),
|
||||
SECURE("Secure"),
|
||||
MAX_AGE("Max-Age")
|
||||
}
|
||||
|
||||
fun HttpServletResponse.addCookie(name: String, value: String, optionsBuilder: CookieOptions.() -> Unit) {
|
||||
this.addHeader("Set-Cookie", buildCookie(name, value, optionsBuilder))
|
||||
}
|
||||
|
||||
private fun buildCookie(name: String, value: String, optionsBuilder: CookieOptions.() -> Unit): String {
|
||||
val options = CookieOptions().apply(optionsBuilder)
|
||||
val cookie = StringBuilder("$name=$value;")
|
||||
|
||||
fun addBoolOption(option: CookieOption, enabled: Boolean) {
|
||||
if (enabled) {
|
||||
cookie.append("${option.optionName};")
|
||||
}
|
||||
}
|
||||
|
||||
fun addOption(option: CookieOption, value: Any) {
|
||||
cookie.append("${option.optionName}=$value;")
|
||||
}
|
||||
|
||||
addBoolOption(CookieOption.HTTP_ONLY, options.httpOnly)
|
||||
addBoolOption(CookieOption.SAME_SITE, options.sameSite)
|
||||
addBoolOption(CookieOption.SECURE, options.secure)
|
||||
addOption(CookieOption.MAX_AGE, options.maxAge)
|
||||
|
||||
return cookie.toString()
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
package dev.fyloz.colorrecipesexplorer.utils
|
||||
|
||||
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 dev.fyloz.colorrecipesexplorer.model.account.toOutputDto
|
||||
import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.io.Encoders
|
||||
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"
|
||||
|
||||
private val objectMapper = jacksonMapperBuilder()
|
||||
.apply { addModule(JavaTimeModule()) }
|
||||
.build()
|
||||
private val serializer = JacksonSerializer<Map<String, *>>(objectMapper)
|
||||
private val deserializer = JacksonDeserializer<Map<String, *>>(objectMapper)
|
||||
|
||||
/** 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())
|
||||
|
||||
return Jwts.builder()
|
||||
.serializeToJsonWith(serializer)
|
||||
.signWith(keyFromSecret(secret))
|
||||
.setSubject(this.id.toString())
|
||||
.setExpiration(expirationDate)
|
||||
.claim(userClaimName, serializedUser)
|
||||
.compact()
|
||||
}
|
||||
|
||||
/** Parses the user of the given [jwt]. */
|
||||
fun parseJwtUser(jwt: String, secret: String): UserOutputDto =
|
||||
with(
|
||||
Jwts.parserBuilder()
|
||||
.deserializeJsonWith(deserializer)
|
||||
.setSigningKey(keyFromSecret(secret))
|
||||
.build()
|
||||
.parseClaimsJws(jwt)
|
||||
.body.get(userClaimName, String::class.java)
|
||||
) {
|
||||
objectMapper.readValue(this)
|
||||
}
|
||||
|
||||
/** Creates a base64 encoded [SecretKey] from the given [secret]. */
|
||||
private fun keyFromSecret(secret: String): SecretKey =
|
||||
with(Encoders.BASE64.encode(secret.toByteArray())) {
|
||||
Keys.hmacShaKeyFor(this.toByteArray())
|
||||
}
|
Loading…
Reference in New Issue