Compare commits
2 Commits
975ebae553
...
72f9710bdb
Author | SHA1 | Date |
---|---|---|
William Nolin | 72f9710bdb | |
William Nolin | da2a824414 |
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue