Compare commits

..

2 Commits

Author SHA1 Message Date
FyloZ 1b3e5c23a7
Add cookie extension function to HttpServletResponse 2021-10-12 21:45:21 -04:00
FyloZ 1216ace314
#12 JWT 2021-09-11 23:48:23 -04:00
8 changed files with 183 additions and 183 deletions

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,12 +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.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.utils.buildJwt
import dev.fyloz.colorrecipesexplorer.utils.parseJwt
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
@ -23,14 +23,14 @@ 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() {
private val objectMapper = jacksonObjectMapper()
private var debugMode = false
init {
@ -39,7 +39,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))
}
@ -49,25 +49,23 @@ class JwtAuthenticationFilter(
chain: FilterChain,
auth: Authentication
) {
val userDetails = (auth.principal as UserDetails)
val token =
userDetails.user.buildJwt(securityProperties.jwtSecret, duration = securityProperties.jwtDuration).token
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) {
@ -104,18 +102,23 @@ class JwtAuthorizationFilter(
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
return try {
with(parseJwt<UserOutputDto>(token.replace("Bearer", ""), securityProperties.jwtSecret)) {
getAuthenticationToken(this.subject)
}
val user = jwtService.parseJwt(token.replace("Bearer", ""))
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())
}

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

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

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

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

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,139 +0,0 @@
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 dev.fyloz.colorrecipesexplorer.model.account.User
import dev.fyloz.colorrecipesexplorer.model.account.userOutputDto
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
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.security.Keys
import java.util.*
import javax.crypto.SecretKey
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)
duration?.let {
val expirationMs = System.currentTimeMillis() + it
val expirationDate = Date(expirationMs)
builder.setExpiration(expirationDate)
}
builder.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) =
with(
Jwts.parserBuilder()
.deserializeJsonWith(JacksonDeserializer(mapOf("payload" to B::class.java)))
.setSigningKey(keyFromSecret(secret))
.build()
.parseClaimsJws(jwt)
) {
val jwt = Jwt<B>(this.body.subject, secret)
val payload = this.body.get("payload", B::class.java)
jwt
}
/** Creates a base64 encoded [SecretKey] from the given [secret]. */
fun keyFromSecret(secret: String) =
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)