Move user infos to JWT tokens #19

Merged
william merged 12 commits from feature/12-user-info-in-jwt into develop 2021-12-02 21:58:27 -05:00
6 changed files with 160 additions and 77 deletions
Showing only changes of commit 1b3e5c23a7 - Show all commits

View File

@ -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}")

View File

@ -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

View File

@ -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)
}
)

View File

@ -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())
}

View File

@ -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()
}

View File

@ -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())
}