Compare commits

...

2 Commits

12 changed files with 342 additions and 198 deletions

View File

@ -30,9 +30,11 @@ dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}")
implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.11.3")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.4")
implementation("javax.xml.bind:jaxb-api:2.3.0")
implementation("io.jsonwebtoken:jjwt-api:0.11.2")
implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
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")
@ -58,9 +60,6 @@ dependencies {
runtimeOnly("mysql:mysql-connector-java:8.0.22")
runtimeOnly("org.postgresql:postgresql:42.2.16")
runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:9.2.1.jre11")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2")
}
springBoot {

View File

@ -3,8 +3,10 @@ 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 io.jsonwebtoken.ExpiredJwtException
@ -28,6 +30,7 @@ class JwtAuthenticationFilter(
private val securityProperties: CreSecurityProperties,
private val updateUserLoginTime: (Long) -> Unit
) : UsernamePasswordAuthenticationFilter() {
private val objectMapper = jacksonObjectMapper()
private var debugMode = false
init {
@ -36,7 +39,7 @@ class JwtAuthenticationFilter(
}
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java)
val loginRequest = objectMapper.readValue(request.inputStream, UserLoginRequest::class.java)
return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password))
}
@ -101,7 +104,7 @@ class JwtAuthorizationFilter(
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
return try {
with(parseJwt(token.replace("Bearer", ""), securityProperties.jwtSecret)) {
with(parseJwt<UserOutputDto>(token.replace("Bearer", ""), securityProperties.jwtSecret)) {
getAuthenticationToken(this.subject)
}
} catch (_: ExpiredJwtException) {

View File

@ -4,10 +4,8 @@ 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.model.account.UserDetails
import dev.fyloz.colorrecipesexplorer.model.account.user
import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsService
import dev.fyloz.colorrecipesexplorer.service.UserService
import dev.fyloz.colorrecipesexplorer.service.users.UserDetailsService
import dev.fyloz.colorrecipesexplorer.service.users.UserService
import org.slf4j.Logger
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
@ -23,8 +21,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
@ -43,7 +39,7 @@ import javax.servlet.http.HttpServletResponse
@EnableConfigurationProperties(CreSecurityProperties::class)
class SecurityConfig(
private val securityProperties: CreSecurityProperties,
@Lazy private val userDetailsService: CreUserDetailsService,
@Lazy private val userDetailsService: UserDetailsService,
@Lazy private val userService: UserService,
private val environment: Environment,
private val logger: Logger
@ -120,6 +116,7 @@ class SecurityConfig(
@EnableConfigurationProperties(CreSecurityProperties::class)
class EmergencySecurityConfig(
private val securityProperties: CreSecurityProperties,
private val userDetailsService: UserDetailsService,
private val environment: Environment
) : WebSecurityConfigurerAdapter() {
init {
@ -134,13 +131,8 @@ class EmergencySecurityConfig(
fun passwordEncoder() =
getPasswordEncoder()
override fun configure(auth: AuthenticationManagerBuilder) {
assertRootUserNotNull(securityProperties)
// Create in-memory root user
auth.inMemoryAuthentication()
.withUser(securityProperties.root!!.id.toString())
.password(passwordEncoder().encode(securityProperties.root!!.password))
.authorities(SimpleGrantedAuthority(Permission.ADMIN.name))
override fun configure(authBuilder: AuthenticationManagerBuilder) {
authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder())
}
override fun configure(http: HttpSecurity) {
@ -154,7 +146,9 @@ class EmergencySecurityConfig(
JwtAuthenticationFilter(authenticationManager(), securityProperties) { }
)
.addFilter(
JwtAuthorizationFilter(securityProperties, authenticationManager(), this::loadUserById)
JwtAuthorizationFilter(securityProperties, authenticationManager()) {
userDetailsService.loadUserById(it, false)
}
)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
@ -166,18 +160,6 @@ class EmergencySecurityConfig(
http.cors()
}
}
private fun loadUserById(id: Long): UserDetails {
assertRootUserNotNull(securityProperties)
if (id == securityProperties.root!!.id) {
return UserDetails(user(
id = id,
password = securityProperties.root!!.password,
permissions = mutableSetOf(Permission.ADMIN)
))
}
throw UsernameNotFoundException(id.toString())
}
}
@Component

View File

@ -123,11 +123,10 @@ data class UserDetails(val user: User) : SpringUserDetails {
// ==== DSL ====
fun user(
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = passwordEncoder.encode("password"),
password: String = "password",
isDefaultGroupUser: Boolean = false,
isSystemUser: Boolean = false,
group: Group? = null,
@ -146,6 +145,30 @@ fun user(
lastLoginTime
).apply(op)
fun user(
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
plainPassword: String = "password",
isDefaultGroupUser: Boolean = false,
isSystemUser: Boolean = false,
group: Group? = null,
permissions: MutableSet<Permission> = mutableSetOf(),
lastLoginTime: LocalDateTime? = null,
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
op: User.() -> Unit = {}
) = User(
id,
firstName,
lastName,
passwordEncoder.encode(plainPassword),
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
).apply(op)
fun userSaveDto(
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
@ -166,6 +189,18 @@ 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
)
// ==== Exceptions ====
private const val USER_NOT_FOUND_EXCEPTION_TITLE = "User not found"
private const val USER_ALREADY_EXISTS_EXCEPTION_TITLE = "User already exists"

View File

@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers
import dev.fyloz.colorrecipesexplorer.model.account.*
import dev.fyloz.colorrecipesexplorer.service.UserService
import dev.fyloz.colorrecipesexplorer.service.GroupService
import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import dev.fyloz.colorrecipesexplorer.service.users.UserService
import org.springframework.context.annotation.Profile
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize

View File

@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.validation.or
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile

View File

@ -0,0 +1,97 @@
package dev.fyloz.colorrecipesexplorer.service.users
import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
import dev.fyloz.colorrecipesexplorer.model.account.*
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
import dev.fyloz.colorrecipesexplorer.service.AbstractExternalNamedModelService
import dev.fyloz.colorrecipesexplorer.service.ExternalNamedModelService
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
import org.springframework.web.util.WebUtils
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.transaction.Transactional
const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans
interface GroupService :
ExternalNamedModelService<Group, GroupSaveDto, GroupUpdateDto, GroupOutputDto, GroupRepository> {
/** Gets all the users of the group with the given [id]. */
fun getUsersForGroup(id: Long): Collection<User>
/** Gets the default group from a cookie in the given HTTP [request]. */
fun getRequestDefaultGroup(request: HttpServletRequest): Group
/** Sets the default group cookie for the given HTTP [response]. */
fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse)
}
@Service
@Profile("!emergency")
class GroupServiceImpl(
private val userService: UserService,
groupRepository: GroupRepository
) : AbstractExternalNamedModelService<Group, GroupSaveDto, GroupUpdateDto, GroupOutputDto, GroupRepository>(
groupRepository
),
GroupService {
override fun idNotFoundException(id: Long) = groupIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = groupIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = groupNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = groupNameAlreadyExistsException(name)
override fun Group.toOutput() = GroupOutputDto(
this.id!!,
this.name,
this.permissions,
this.flatPermissions
)
override fun existsByName(name: String): Boolean = repository.existsByName(name)
override fun getUsersForGroup(id: Long): Collection<User> =
userService.getByGroup(getById(id))
@Transactional
override fun save(entity: Group): Group {
return super<AbstractExternalNamedModelService>.save(entity).apply {
userService.saveDefaultGroupUser(this)
}
}
override fun update(entity: GroupUpdateDto): Group {
val persistedGroup by lazy { getById(entity.id) }
return update(with(entity) {
Group(
entity.id,
if (name.isNotBlank()) entity.name else persistedGroup.name,
if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions
)
})
}
@Transactional
override fun delete(entity: Group) {
userService.delete(userService.getDefaultGroupUser(entity))
super.delete(entity)
}
override fun getRequestDefaultGroup(request: HttpServletRequest): Group {
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
?: throw NoDefaultGroupException()
val defaultGroupUser = userService.getById(
defaultGroupCookie.value.toLong(),
ignoreDefaultGroupUsers = false,
ignoreSystemUsers = true
)
return defaultGroupUser.group!!
}
override fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse) {
val group = getById(groupId)
val defaultGroupUser = userService.getDefaultGroupUser(group)
response.addHeader(
"Set-Cookie",
"$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict"
)
}
}

View File

@ -0,0 +1,77 @@
package dev.fyloz.colorrecipesexplorer.service.users
import dev.fyloz.colorrecipesexplorer.SpringUserDetails
import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.User
import dev.fyloz.colorrecipesexplorer.model.account.UserDetails
import dev.fyloz.colorrecipesexplorer.model.account.user
import org.springframework.context.annotation.Profile
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
interface UserDetailsService : SpringUserDetailsService {
/** Loads an [User] for the given [id]. */
fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean = false): UserDetails
}
@Service
@Profile("!emergency")
class UserDetailsServiceImpl(
private val userService: UserService
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
try {
return loadUserById(username.toLong(), true)
} catch (ex: NotFoundException) {
throw UsernameNotFoundException(username)
}
}
override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails {
val user = userService.getById(
id,
ignoreDefaultGroupUsers = ignoreDefaultGroupUsers,
ignoreSystemUsers = false
)
return UserDetails(user)
}
}
@Service
@Profile("emergency")
class EmergencyUserDetailsServiceImpl(
securityProperties: CreSecurityProperties
) : UserDetailsService {
private val users: Set<User>
init {
if (securityProperties.root == null) {
throw NullPointerException("The root user has not been configured")
}
users = setOf(
// Add root user
with(securityProperties.root!!) {
user(
id = this.id,
plainPassword = this.password,
permissions = mutableSetOf(Permission.ADMIN)
)
}
)
}
override fun loadUserByUsername(username: String): SpringUserDetails {
return loadUserById(username.toLong(), true)
}
override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails {
val user = users.firstOrNull { it.id == id }
?: throw UsernameNotFoundException(id.toString())
return UserDetails(user)
}
}

View File

@ -1,23 +1,18 @@
package dev.fyloz.colorrecipesexplorer.service
package dev.fyloz.colorrecipesexplorer.service.users
import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService
import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens
import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.account.*
import dev.fyloz.colorrecipesexplorer.model.validation.or
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
import dev.fyloz.colorrecipesexplorer.repository.UserRepository
import dev.fyloz.colorrecipesexplorer.service.AbstractExternalModelService
import dev.fyloz.colorrecipesexplorer.service.ExternalModelService
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.web.util.WebUtils
import java.time.LocalDateTime
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.transaction.Transactional
interface UserService :
ExternalModelService<User, UserSaveDto, UserUpdateDto, UserOutputDto, UserRepository> {
@ -55,29 +50,11 @@ interface UserService :
fun logout(request: HttpServletRequest)
}
interface GroupService :
ExternalNamedModelService<Group, GroupSaveDto, GroupUpdateDto, GroupOutputDto, GroupRepository> {
/** Gets all the users of the group with the given [id]. */
fun getUsersForGroup(id: Long): Collection<User>
/** Gets the default group from a cookie in the given HTTP [request]. */
fun getRequestDefaultGroup(request: HttpServletRequest): Group
/** Sets the default group cookie for the given HTTP [response]. */
fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse)
}
interface CreUserDetailsService : SpringUserDetailsService {
/** Loads an [User] for the given [id]. */
fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean = false): UserDetails
}
@Service
@Profile("!emergency")
class UserServiceImpl(
userRepository: UserRepository,
@Lazy val groupService: GroupService,
@Lazy val passwordEncoder: PasswordEncoder,
) : AbstractExternalModelService<User, UserSaveDto, UserUpdateDto, UserOutputDto, UserRepository>(
userRepository
),
@ -85,15 +62,7 @@ class UserServiceImpl(
override fun idNotFoundException(id: Long) = userIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = userIdAlreadyExistsException(id)
override fun User.toOutput() = UserOutputDto(
this.id,
this.firstName,
this.lastName,
this.group,
this.flatPermissions,
this.permissions,
this.lastLoginTime
)
override fun User.toOutput() = userOutputDto(this)
override fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean =
repository.existsByFirstNameAndLastName(firstName, lastName)
@ -120,11 +89,11 @@ class UserServiceImpl(
override fun save(entity: UserSaveDto): User =
save(with(entity) {
User(
id,
firstName,
lastName,
passwordEncoder.encode(password),
user(
id = id,
firstName = firstName,
lastName = lastName,
plainPassword = password,
isDefaultGroupUser = false,
isSystemUser = false,
group = if (groupId != null) groupService.getById(groupId) else null,
@ -146,7 +115,7 @@ class UserServiceImpl(
id = 1000000L + group.id!!,
firstName = group.name,
lastName = "User",
password = passwordEncoder.encode(group.name),
plainPassword = group.name,
group = group,
isDefaultGroupUser = true
)
@ -195,11 +164,11 @@ class UserServiceImpl(
override fun updatePassword(id: Long, password: String): User {
val persistedUser = getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true)
return super.update(with(persistedUser) {
User(
user(
id,
firstName,
lastName,
passwordEncoder.encode(password),
plainPassword = password,
isDefaultGroupUser,
isSystemUser,
group,
@ -225,100 +194,3 @@ class UserServiceImpl(
}
}
}
const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans
@Service
@Profile("!emergency")
class GroupServiceImpl(
private val userService: UserService,
groupRepository: GroupRepository
) : AbstractExternalNamedModelService<Group, GroupSaveDto, GroupUpdateDto, GroupOutputDto, GroupRepository>(
groupRepository
),
GroupService {
override fun idNotFoundException(id: Long) = groupIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = groupIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = groupNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = groupNameAlreadyExistsException(name)
override fun Group.toOutput() = GroupOutputDto(
this.id!!,
this.name,
this.permissions,
this.flatPermissions
)
override fun existsByName(name: String): Boolean = repository.existsByName(name)
override fun getUsersForGroup(id: Long): Collection<User> =
userService.getByGroup(getById(id))
@Transactional
override fun save(entity: Group): Group {
return super<AbstractExternalNamedModelService>.save(entity).apply {
userService.saveDefaultGroupUser(this)
}
}
override fun update(entity: GroupUpdateDto): Group {
val persistedGroup by lazy { getById(entity.id) }
return update(with(entity) {
Group(
entity.id,
if (name.isNotBlank()) entity.name else persistedGroup.name,
if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions
)
})
}
@Transactional
override fun delete(entity: Group) {
userService.delete(userService.getDefaultGroupUser(entity))
super.delete(entity)
}
override fun getRequestDefaultGroup(request: HttpServletRequest): Group {
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
?: throw NoDefaultGroupException()
val defaultGroupUser = userService.getById(
defaultGroupCookie.value.toLong(),
ignoreDefaultGroupUsers = false,
ignoreSystemUsers = true
)
return defaultGroupUser.group!!
}
override fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse) {
val group = getById(groupId)
val defaultGroupUser = userService.getDefaultGroupUser(group)
response.addHeader(
"Set-Cookie",
"$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict"
)
}
}
@Service
@Profile("!emergency")
class CreUserDetailsServiceImpl(
private val userService: UserService
) : CreUserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
try {
return loadUserById(username.toLong(), true)
} catch (ex: NotFoundException) {
throw UsernameNotFoundException(username)
} catch (ex: NotFoundException) {
throw UsernameNotFoundException(username)
}
}
override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails {
val user = userService.getById(
id,
ignoreDefaultGroupUsers = ignoreDefaultGroupUsers,
ignoreSystemUsers = false
)
return UserDetails(user)
}
}

View File

@ -1,25 +1,40 @@
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(
data class Jwt<B>(
val subject: String,
val secret: String,
val duration: Long? = null,
val signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithm.HS512,
val claims: Map<String, Any?> = mapOf()
val body: B? = null
) {
val token: String by lazy {
val builder = Jwts.builder()
.signWith(keyFromSecret(secret))
.setSubject(subject)
.addClaims(claims.filterValues { it != null })
.claim("payload", body)
duration?.let {
val expirationMs = System.currentTimeMillis() + it
@ -32,31 +47,93 @@ data class Jwt(
}
}
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,
claims = mapOf(
"groupId" to this.group?.id,
"groupName" to this.group?.name
)
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. */
fun parseJwt(jwt: String, secret: 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)
) {
Jwt(this.body.subject, secret)
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]. */
private fun keyFromSecret(secret: String) =
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)

View File

@ -5,8 +5,9 @@ import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.account.*
import dev.fyloz.colorrecipesexplorer.repository.UserRepository
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
import dev.fyloz.colorrecipesexplorer.repository.UserRepository
import dev.fyloz.colorrecipesexplorer.service.users.*
import org.junit.jupiter.api.*
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.security.core.userdetails.UsernameNotFoundException
@ -18,24 +19,23 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import org.springframework.security.core.userdetails.User as SpringUser
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserServiceTest :
AbstractExternalModelServiceTest<User, UserSaveDto, UserUpdateDto, UserService, UserRepository>() {
private val passwordEncoder = BCryptPasswordEncoder()
override val entity: User = user(passwordEncoder, id = 0L)
override val anotherEntity: User = user(passwordEncoder, id = 1L)
private val entityDefaultGroupUser = user(passwordEncoder, id = 2L, isDefaultGroupUser = true)
private val entitySystemUser = user(passwordEncoder, id = 3L, isSystemUser = true)
override val entity: User = user(id = 0L, passwordEncoder = passwordEncoder)
override val anotherEntity: User = user(id = 1L, passwordEncoder = passwordEncoder)
private val entityDefaultGroupUser = user(id = 2L, isDefaultGroupUser = true, passwordEncoder = passwordEncoder)
private val entitySystemUser = user(id = 3L, isSystemUser = true, passwordEncoder = passwordEncoder)
private val group = group(id = 0L)
override val entitySaveDto: UserSaveDto = spy(userSaveDto(passwordEncoder, id = 0L))
override val entityUpdateDto: UserUpdateDto = spy(userUpdateDto(id = 0L))
override val repository: UserRepository = mock()
private val groupService: GroupService = mock()
override val service: UserService = spy(UserServiceImpl(repository, groupService, passwordEncoder))
override val service: UserService = spy(UserServiceImpl(repository, groupService))
private val entitySaveDtoUser = User(
entitySaveDto.id,
@ -210,7 +210,7 @@ class GroupServiceTest :
override val entityWithEntityName: Group = group(id = 2L, name = entity.name)
private val groupUserId = 1000000L + entity.id!!
private val groupUser = user(BCryptPasswordEncoder(), id = groupUserId, group = entity)
private val groupUser = user(passwordEncoder = BCryptPasswordEncoder(), id = groupUserId, group = entity)
@BeforeEach
override fun afterEach() {
@ -303,7 +303,7 @@ class GroupServiceTest :
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserUserDetailsServiceTest {
private val userService: UserService = mock()
private val service = spy(CreUserDetailsServiceImpl(userService))
private val service = spy(UserDetailsServiceImpl(userService))
private val user = user(id = 0L)
@ -317,8 +317,8 @@ class UserUserDetailsServiceTest {
@Test
fun `loadUserByUsername() calls loadUserByUserId() with the given username as an id`() {
whenever(userService.getById(eq(user.id), any(), any())).doReturn(user)
doReturn(SpringUser(user.id.toString(), user.password, listOf())).whenever(service)
.loadUserById(user.id)
doReturn(UserDetails(user(id = user.id, plainPassword = user.password)))
.whenever(service).loadUserById(user.id)
service.loadUserByUsername(user.id.toString())

View File

@ -6,6 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.account.group
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test