diff --git a/build.gradle.kts b/build.gradle.kts index 7d857c0..c997a35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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}") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt index d4a36bf..18a9711 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -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() +val blacklistedJwtTokens = mutableListOf() // 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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index 323706e..cb48092 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -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) } ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/JwtService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/JwtService.kt new file mode 100644 index 0000000..46dcfc1 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/JwtService.kt @@ -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>(objectMapper)) + .signWith(secretKey) + } + + private val jwtParser by lazy { + Jwts.parserBuilder() + .deserializeJsonWith(JacksonDeserializer>(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()) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt new file mode 100644 index 0000000..5eb4c2f --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt @@ -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() +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt deleted file mode 100644 index 280e7e0..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt +++ /dev/null @@ -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>(objectMapper) -private val deserializer = JacksonDeserializer>(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()) - }