Add cookie extension function to HttpServletResponse
This commit is contained in:
parent
1216ace314
commit
1b3e5c23a7
|
@ -22,7 +22,7 @@ repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|
||||||
maven {
|
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("io.jsonwebtoken:jjwt-jackson:0.11.2")
|
||||||
implementation("org.apache.poi:poi-ooxml:4.1.0")
|
implementation("org.apache.poi:poi-ooxml:4.1.0")
|
||||||
implementation("org.apache.pdfbox:pdfbox:2.0.4")
|
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-data-jpa:${springBootVersion}")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-jdbc:${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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.*
|
import dev.fyloz.colorrecipesexplorer.model.account.UserDetails
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.buildJwt
|
import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.parseJwtUser
|
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 io.jsonwebtoken.ExpiredJwtException
|
||||||
import org.springframework.security.authentication.AuthenticationManager
|
import org.springframework.security.authentication.AuthenticationManager
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
@ -20,10 +23,11 @@ import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
const val authorizationCookieName = "Authorization"
|
const val authorizationCookieName = "Authorization"
|
||||||
const val defaultGroupCookieName = "Default-Group"
|
const val defaultGroupCookieName = "Default-Group"
|
||||||
val blacklistedJwtTokens = mutableListOf<String>()
|
val blacklistedJwtTokens = mutableListOf<String>() // Not working, move to a cache or something
|
||||||
|
|
||||||
class JwtAuthenticationFilter(
|
class JwtAuthenticationFilter(
|
||||||
private val authManager: AuthenticationManager,
|
private val authManager: AuthenticationManager,
|
||||||
|
private val jwtService: JwtService,
|
||||||
private val securityProperties: CreSecurityProperties,
|
private val securityProperties: CreSecurityProperties,
|
||||||
private val updateUserLoginTime: (Long) -> Unit
|
private val updateUserLoginTime: (Long) -> Unit
|
||||||
) : UsernamePasswordAuthenticationFilter() {
|
) : UsernamePasswordAuthenticationFilter() {
|
||||||
|
@ -45,24 +49,23 @@ class JwtAuthenticationFilter(
|
||||||
chain: FilterChain,
|
chain: FilterChain,
|
||||||
auth: Authentication
|
auth: Authentication
|
||||||
) {
|
) {
|
||||||
val userDetails = (auth.principal as UserDetails)
|
val userDetails = auth.principal as UserDetails
|
||||||
val token = userDetails.user.buildJwt(securityProperties.jwtSecret, securityProperties.jwtDuration)
|
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.addHeader(authorizationCookieName, "Bearer $token")
|
||||||
|
response.addCookie(authorizationCookieName, "Bearer$token") {
|
||||||
|
httpOnly = true
|
||||||
|
sameSite = true
|
||||||
|
secure = !debugMode
|
||||||
|
maxAge = securityProperties.jwtDuration / 1000
|
||||||
|
}
|
||||||
|
|
||||||
updateUserLoginTime(userDetails.user.id)
|
updateUserLoginTime(userDetails.user.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class JwtAuthorizationFilter(
|
class JwtAuthorizationFilter(
|
||||||
private val securityProperties: CreSecurityProperties,
|
private val jwtService: JwtService,
|
||||||
authenticationManager: AuthenticationManager,
|
authenticationManager: AuthenticationManager,
|
||||||
private val loadUserById: (Long) -> UserDetails
|
private val loadUserById: (Long) -> UserDetails
|
||||||
) : BasicAuthenticationFilter(authenticationManager) {
|
) : BasicAuthenticationFilter(authenticationManager) {
|
||||||
|
@ -99,7 +102,7 @@ class JwtAuthorizationFilter(
|
||||||
|
|
||||||
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
|
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
|
||||||
return try {
|
return try {
|
||||||
val user = parseJwtUser(token.replace("Bearer", ""), securityProperties.jwtSecret)
|
val user = jwtService.parseJwt(token.replace("Bearer", ""))
|
||||||
getAuthenticationToken(user)
|
getAuthenticationToken(user)
|
||||||
} catch (_: ExpiredJwtException) {
|
} catch (_: ExpiredJwtException) {
|
||||||
null
|
null
|
||||||
|
|
|
@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
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.UserDetailsService
|
||||||
import dev.fyloz.colorrecipesexplorer.service.users.UserService
|
import dev.fyloz.colorrecipesexplorer.service.users.UserService
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
|
@ -41,6 +42,7 @@ class SecurityConfig(
|
||||||
private val securityProperties: CreSecurityProperties,
|
private val securityProperties: CreSecurityProperties,
|
||||||
@Lazy private val userDetailsService: UserDetailsService,
|
@Lazy private val userDetailsService: UserDetailsService,
|
||||||
@Lazy private val userService: UserService,
|
@Lazy private val userService: UserService,
|
||||||
|
private val jwtService: JwtService,
|
||||||
private val environment: Environment,
|
private val environment: Environment,
|
||||||
private val logger: Logger
|
private val logger: Logger
|
||||||
) : WebSecurityConfigurerAdapter() {
|
) : WebSecurityConfigurerAdapter() {
|
||||||
|
@ -86,12 +88,12 @@ class SecurityConfig(
|
||||||
.and()
|
.and()
|
||||||
.csrf().disable()
|
.csrf().disable()
|
||||||
.addFilter(
|
.addFilter(
|
||||||
JwtAuthenticationFilter(authenticationManager(), securityProperties) {
|
JwtAuthenticationFilter(authenticationManager(), jwtService, securityProperties) {
|
||||||
userService.updateLastLoginTime(it)
|
userService.updateLastLoginTime(it)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.addFilter(
|
.addFilter(
|
||||||
JwtAuthorizationFilter(securityProperties, authenticationManager()) {
|
JwtAuthorizationFilter(jwtService, authenticationManager()) {
|
||||||
userDetailsService.loadUserById(it, false)
|
userDetailsService.loadUserById(it, false)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -117,6 +119,7 @@ class SecurityConfig(
|
||||||
class EmergencySecurityConfig(
|
class EmergencySecurityConfig(
|
||||||
private val securityProperties: CreSecurityProperties,
|
private val securityProperties: CreSecurityProperties,
|
||||||
private val userDetailsService: UserDetailsService,
|
private val userDetailsService: UserDetailsService,
|
||||||
|
private val jwtService: JwtService,
|
||||||
private val environment: Environment
|
private val environment: Environment
|
||||||
) : WebSecurityConfigurerAdapter() {
|
) : WebSecurityConfigurerAdapter() {
|
||||||
init {
|
init {
|
||||||
|
@ -143,10 +146,10 @@ class EmergencySecurityConfig(
|
||||||
.and()
|
.and()
|
||||||
.csrf().disable()
|
.csrf().disable()
|
||||||
.addFilter(
|
.addFilter(
|
||||||
JwtAuthenticationFilter(authenticationManager(), securityProperties) { }
|
JwtAuthenticationFilter(authenticationManager(), jwtService, securityProperties) { }
|
||||||
)
|
)
|
||||||
.addFilter(
|
.addFilter(
|
||||||
JwtAuthorizationFilter(securityProperties, authenticationManager()) {
|
JwtAuthorizationFilter(jwtService, authenticationManager()) {
|
||||||
userDetailsService.loadUserById(it, false)
|
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