feature/#30-group-authentication #31
|
@ -1,18 +1,30 @@
|
|||
package dev.fyloz.colorrecipesexplorer
|
||||
|
||||
object Constants {
|
||||
var DEBUG_MODE = false // Not really a constant, but should never change after the app startup
|
||||
|
||||
object ControllerPaths {
|
||||
const val COMPANY = "/api/company"
|
||||
const val GROUP_TOKEN = "/api/account/group/token"
|
||||
const val FILE = "/api/file"
|
||||
const val GROUP = "/api/account/group"
|
||||
const val INVENTORY = "/api/inventory"
|
||||
const val MATERIAL = "/api/material"
|
||||
const val MATERIAL_TYPE = "/api/materialtype"
|
||||
const val MIX = "/api/recipe/mix"
|
||||
const val RECIPE = "/api/recipe"
|
||||
const val TOUCH_UP_KIT = "/api/touchupkit"
|
||||
const val USER = "/api/account/user"
|
||||
const val BASE_PATH = "/api"
|
||||
|
||||
const val COMPANY = "$BASE_PATH/company"
|
||||
const val GROUP_TOKEN = "$BASE_PATH/account/group/token"
|
||||
const val FILE = "$BASE_PATH/file"
|
||||
const val GROUP = "$BASE_PATH/account/group"
|
||||
const val INVENTORY = "$BASE_PATH/inventory"
|
||||
const val MATERIAL = "$BASE_PATH/material"
|
||||
const val MATERIAL_TYPE = "$BASE_PATH/materialtype"
|
||||
const val MIX = "$BASE_PATH/recipe/mix"
|
||||
const val RECIPE = "$BASE_PATH/recipe"
|
||||
const val TOUCH_UP_KIT = "$BASE_PATH/touchupkit"
|
||||
const val USER = "$BASE_PATH/account/user"
|
||||
|
||||
const val LOGIN = "$BASE_PATH/account/login"
|
||||
const val GROUP_LOGIN = "$BASE_PATH/account/login/group"
|
||||
}
|
||||
|
||||
object CookieNames {
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
const val GROUP_TOKEN = "Group-Token"
|
||||
}
|
||||
|
||||
object FilePaths {
|
||||
|
@ -24,6 +36,11 @@ object Constants {
|
|||
const val RECIPE_IMAGES = "$IMAGES/recipes"
|
||||
}
|
||||
|
||||
object HeaderNames {
|
||||
const val ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
}
|
||||
|
||||
object ModelNames {
|
||||
const val COMPANY = "Company"
|
||||
const val GROUP_TOKEN = "GroupToken"
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import java.util.*
|
||||
|
||||
class GroupAuthenticationToken(val id: String) : AbstractAuthenticationToken(null) {
|
||||
override fun getPrincipal() = id
|
||||
|
||||
// There is no credential needed to log in with a group token, just use the group token id
|
||||
override fun getCredentials() = id
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic
|
||||
import org.springframework.security.authentication.AuthenticationProvider
|
||||
import org.springframework.security.authentication.BadCredentialsException
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.Authentication
|
||||
import java.util.*
|
||||
|
||||
class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLogic) : AuthenticationProvider {
|
||||
override fun authenticate(authentication: Authentication): Authentication {
|
||||
val groupAuthenticationToken = authentication as GroupAuthenticationToken
|
||||
|
||||
val groupTokenId = parseGroupTokenId(groupAuthenticationToken.id)
|
||||
val groupToken = retrieveGroupToken(groupTokenId)
|
||||
|
||||
val userDetails = UserDetails(groupToken.id, groupToken.name, "", groupToken.group.id, groupToken.group.permissions)
|
||||
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
|
||||
}
|
||||
|
||||
override fun supports(authentication: Class<*>) =
|
||||
authentication.isAssignableFrom(GroupAuthenticationToken::class.java)
|
||||
|
||||
private fun parseGroupTokenId(id: String) = try {
|
||||
UUID.fromString(id)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw BadCredentialsException("Group token id must be a valid UUID")
|
||||
}
|
||||
|
||||
private fun retrieveGroupToken(id: UUID) = try {
|
||||
groupTokenLogic.getById(id)
|
||||
} catch (_: NotFoundException) {
|
||||
throw BadCredentialsException("Failed to find group token with id '$id'")
|
||||
}
|
||||
}
|
|
@ -1,8 +1,13 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.filters.GroupTokenAuthenticationFilter
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.filters.UsernamePasswordAuthenticationFilter
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.filters.JwtAuthorizationFilter
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic
|
||||
|
@ -14,6 +19,7 @@ import org.springframework.context.annotation.Bean
|
|||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Lazy
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.core.env.Environment
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
|
||||
|
@ -25,6 +31,7 @@ import org.springframework.security.config.http.SessionCreationPolicy
|
|||
import org.springframework.security.core.AuthenticationException
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||
|
@ -39,13 +46,13 @@ private const val rootUserLastName = "User"
|
|||
abstract class BaseSecurityConfig(
|
||||
private val userDetailsLogic: UserDetailsLogic,
|
||||
private val jwtLogic: JwtLogic,
|
||||
private val groupTokenLogic: GroupTokenLogic,
|
||||
private val environment: Environment,
|
||||
protected val securityProperties: CreSecurityProperties
|
||||
) : WebSecurityConfigurerAdapter() {
|
||||
protected abstract val logger: Logger
|
||||
|
||||
protected val passwordEncoder = BCryptPasswordEncoder()
|
||||
var debugMode = false
|
||||
|
||||
@Bean
|
||||
open fun passwordEncoder() =
|
||||
|
@ -69,21 +76,30 @@ abstract class BaseSecurityConfig(
|
|||
}
|
||||
|
||||
override fun configure(authBuilder: AuthenticationManagerBuilder) {
|
||||
authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder)
|
||||
authBuilder
|
||||
.authenticationProvider(GroupTokenAuthenticationProvider(groupTokenLogic))
|
||||
.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder)
|
||||
}
|
||||
|
||||
override fun configure(http: HttpSecurity) {
|
||||
val authManager = authenticationManager()
|
||||
|
||||
http
|
||||
.headers().frameOptions().disable()
|
||||
.and()
|
||||
.csrf().disable()
|
||||
.addFilter(
|
||||
JwtAuthenticationFilter(
|
||||
authenticationManager(),
|
||||
.addFilterBefore(
|
||||
GroupTokenAuthenticationFilter(jwtLogic, securityProperties, authManager),
|
||||
BasicAuthenticationFilter::class.java
|
||||
)
|
||||
.addFilterBefore(
|
||||
UsernamePasswordAuthenticationFilter(
|
||||
jwtLogic,
|
||||
securityProperties,
|
||||
authManager,
|
||||
this::updateUserLoginTime
|
||||
)
|
||||
),
|
||||
BasicAuthenticationFilter::class.java
|
||||
)
|
||||
.addFilter(
|
||||
JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic)
|
||||
|
@ -91,11 +107,12 @@ abstract class BaseSecurityConfig(
|
|||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.and()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/config/**").permitAll() // Allow access to logo and icon
|
||||
.antMatchers("/api/login").permitAll() // Allow access to login
|
||||
.antMatchers("**").fullyAuthenticated()
|
||||
// .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon
|
||||
// .antMatchers("/api/account/login/group").permitAll() // Allow access to login
|
||||
// .antMatchers("**").fullyAuthenticated()
|
||||
.antMatchers("**").permitAll()
|
||||
|
||||
if (debugMode) {
|
||||
if (Constants.DEBUG_MODE) {
|
||||
http
|
||||
.cors()
|
||||
}
|
||||
|
@ -103,14 +120,17 @@ abstract class BaseSecurityConfig(
|
|||
|
||||
@PostConstruct
|
||||
fun initDebugMode() {
|
||||
debugMode = "debug" in environment.activeProfiles
|
||||
val debugMode = "debug" in environment.activeProfiles
|
||||
if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!")
|
||||
|
||||
Constants.DEBUG_MODE = debugMode
|
||||
}
|
||||
|
||||
protected open fun updateUserLoginTime(userId: Long) {
|
||||
}
|
||||
}
|
||||
|
||||
@Order(2)
|
||||
@Configuration
|
||||
@Profile("!emergency")
|
||||
@EnableWebSecurity
|
||||
|
@ -120,9 +140,10 @@ class SecurityConfig(
|
|||
@Lazy userDetailsLogic: UserDetailsLogic,
|
||||
@Lazy private val userLogic: UserLogic,
|
||||
jwtLogic: JwtLogic,
|
||||
groupTokenLogic: GroupTokenLogic,
|
||||
environment: Environment,
|
||||
securityProperties: CreSecurityProperties
|
||||
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) {
|
||||
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) {
|
||||
override val logger = KotlinLogging.logger {}
|
||||
|
||||
@PostConstruct
|
||||
|
@ -168,9 +189,10 @@ class SecurityConfig(
|
|||
class EmergencySecurityConfig(
|
||||
userDetailsLogic: UserDetailsLogic,
|
||||
jwtLogic: JwtLogic,
|
||||
groupTokenLogic: GroupTokenLogic,
|
||||
environment: Environment,
|
||||
securityProperties: CreSecurityProperties
|
||||
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) {
|
||||
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) {
|
||||
override val logger = KotlinLogging.logger {}
|
||||
|
||||
init {
|
||||
|
@ -187,5 +209,5 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint {
|
|||
) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
|
||||
}
|
||||
|
||||
private class InvalidSystemUserException(userType: String, message: String) :
|
||||
class InvalidSystemUserException(userType: String, message: String) :
|
||||
RuntimeException("Invalid $userType user: $message")
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security.filters
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.GroupAuthenticationToken
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
|
||||
import org.springframework.security.authentication.AuthenticationManager
|
||||
import org.springframework.security.authentication.BadCredentialsException
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.web.util.WebUtils
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
class GroupTokenAuthenticationFilter(
|
||||
jwtLogic: JwtLogic,
|
||||
securityProperties: CreSecurityProperties,
|
||||
private val authManager: AuthenticationManager
|
||||
) : JwtAuthenticationFilter(Constants.ControllerPaths.GROUP_LOGIN, securityProperties, jwtLogic) {
|
||||
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
|
||||
val groupTokenCookie = getGroupTokenCookie(request)
|
||||
?: throw BadCredentialsException("Required group token cookie was not present")
|
||||
val groupTokenId = groupTokenCookie.value
|
||||
|
||||
logger.debug("Login attempt for group token $groupTokenId")
|
||||
return authManager.authenticate(GroupAuthenticationToken(groupTokenId))
|
||||
}
|
||||
|
||||
override fun afterSuccessfulAuthentication(userDetails: UserDetails) {
|
||||
logger.info("Successful login for group id '${userDetails.groupId}' using token '${userDetails.id}' (${userDetails.username})")
|
||||
}
|
||||
|
||||
private fun getGroupTokenCookie(request: HttpServletRequest) =
|
||||
WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN)
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security.filters
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
|
||||
import dev.fyloz.colorrecipesexplorer.utils.addCookie
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
|
||||
import javax.servlet.FilterChain
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
abstract class JwtAuthenticationFilter(
|
||||
filterProcessesUrl: String,
|
||||
private val securityProperties: CreSecurityProperties,
|
||||
private val jwtLogic: JwtLogic
|
||||
) :
|
||||
AbstractAuthenticationProcessingFilter(
|
||||
AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString())
|
||||
) {
|
||||
override fun successfulAuthentication(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
chain: FilterChain,
|
||||
auth: Authentication
|
||||
) {
|
||||
val userDetails = auth.principal as UserDetails
|
||||
val token = jwtLogic.buildJwt(userDetails)
|
||||
|
||||
addAuthorizationHeaders(response, token)
|
||||
addAuthorizationCookie(response, token)
|
||||
|
||||
afterSuccessfulAuthentication(userDetails)
|
||||
}
|
||||
|
||||
protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails)
|
||||
|
||||
private fun addAuthorizationHeaders(response: HttpServletResponse, token: String) {
|
||||
response.addHeader(Constants.HeaderNames.ACCESS_CONTROL_EXPOSE_HEADERS, Constants.HeaderNames.AUTHORIZATION)
|
||||
response.addHeader(Constants.HeaderNames.AUTHORIZATION, "$BEARER_TOKEN_PREFIX $token")
|
||||
}
|
||||
|
||||
private fun addAuthorizationCookie(response: HttpServletResponse, token: String) {
|
||||
response.addCookie(Constants.CookieNames.AUTHORIZATION, BEARER_TOKEN_PREFIX + token) {
|
||||
httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY
|
||||
sameSite = AUTHORIZATION_COOKIE_SAME_SITE
|
||||
secure = !Constants.DEBUG_MODE
|
||||
maxAge = securityProperties.jwtDuration / 1000
|
||||
path = AUTHORIZATION_COOKIE_PATH
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AUTHORIZATION_COOKIE_HTTP_ONLY = true
|
||||
private const val AUTHORIZATION_COOKIE_SAME_SITE = true
|
||||
private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH
|
||||
|
||||
private const val BEARER_TOKEN_PREFIX = "Bearer"
|
||||
}
|
||||
}
|
|
@ -1,75 +1,20 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security
|
||||
package dev.fyloz.colorrecipesexplorer.config.security.filters
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto
|
||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic
|
||||
import dev.fyloz.colorrecipesexplorer.utils.addCookie
|
||||
import io.jsonwebtoken.ExpiredJwtException
|
||||
import org.springframework.security.authentication.AuthenticationManager
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
||||
import org.springframework.web.util.WebUtils
|
||||
import javax.servlet.FilterChain
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
const val authorizationCookieName = "Authorization"
|
||||
const val defaultGroupCookieName = "Default-Group"
|
||||
val blacklistedJwtTokens = mutableListOf<String>() // Not working, move to a cache or something
|
||||
|
||||
class JwtAuthenticationFilter(
|
||||
private val authManager: AuthenticationManager,
|
||||
private val jwtLogic: JwtLogic,
|
||||
private val securityProperties: CreSecurityProperties,
|
||||
private val updateUserLoginTime: (Long) -> Unit
|
||||
) : UsernamePasswordAuthenticationFilter() {
|
||||
private var debugMode = false
|
||||
|
||||
init {
|
||||
setFilterProcessesUrl("/api/login")
|
||||
debugMode = "debug" in environment.activeProfiles
|
||||
}
|
||||
|
||||
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
|
||||
val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java)
|
||||
logger.debug("Login attempt for user ${loginRequest.id}...")
|
||||
return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password))
|
||||
}
|
||||
|
||||
override fun successfulAuthentication(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
chain: FilterChain,
|
||||
auth: Authentication
|
||||
) {
|
||||
val userDetails = auth.principal as UserDetails
|
||||
val token = jwtLogic.buildJwt(userDetails)
|
||||
|
||||
with(userDetails.user) {
|
||||
logger.info("User ${this.id} (${this.firstName} ${this.lastName}) has logged in successfully")
|
||||
}
|
||||
|
||||
response.addHeader("Access-Control-Expose-Headers", authorizationCookieName)
|
||||
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 jwtLogic: JwtLogic,
|
||||
authenticationManager: AuthenticationManager,
|
||||
|
@ -77,12 +22,12 @@ class JwtAuthorizationFilter(
|
|||
) : BasicAuthenticationFilter(authenticationManager) {
|
||||
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||
fun tryLoginFromBearer(): Boolean {
|
||||
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
|
||||
val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION)
|
||||
// Check for an authorization token cookie or header
|
||||
val authorizationToken = if (authorizationCookie != null)
|
||||
authorizationCookie.value
|
||||
else
|
||||
request.getHeader(authorizationCookieName)
|
||||
request.getHeader(Constants.HeaderNames.AUTHORIZATION)
|
||||
|
||||
// An authorization token is valid if it starts with "Bearer", is not expired and is not blacklisted
|
||||
if (authorizationToken != null && authorizationToken.startsWith("Bearer") && authorizationToken !in blacklistedJwtTokens) {
|
||||
|
@ -103,6 +48,7 @@ class JwtAuthorizationFilter(
|
|||
|
||||
if (!tryLoginFromBearer())
|
||||
tryLoginFromDefaultGroupCookie()
|
||||
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
|
||||
|
@ -115,7 +61,7 @@ class JwtAuthorizationFilter(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getAuthenticationToken(user: UserDto) =
|
||||
private fun getAuthenticationToken(user: UserDetails) =
|
||||
UsernamePasswordAuthenticationToken(user.id, null, user.authorities)
|
||||
|
||||
private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try {
|
|
@ -0,0 +1,39 @@
|
|||
package dev.fyloz.colorrecipesexplorer.config.security.filters
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
|
||||
import org.springframework.security.authentication.AuthenticationManager
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.Authentication
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
const val defaultGroupCookieName = "Default-Group"
|
||||
val blacklistedJwtTokens = mutableListOf<String>()
|
||||
|
||||
class UsernamePasswordAuthenticationFilter(
|
||||
jwtLogic: JwtLogic,
|
||||
securityProperties: CreSecurityProperties,
|
||||
private val authManager: AuthenticationManager,
|
||||
private val updateUserLoginTime: (Long) -> Unit
|
||||
) : JwtAuthenticationFilter(Constants.ControllerPaths.LOGIN, securityProperties, jwtLogic) {
|
||||
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
|
||||
val loginRequest = getLoginRequest(request)
|
||||
val authenticationToken = UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)
|
||||
|
||||
logger.debug("Login attempt for user ${loginRequest.id}")
|
||||
return authManager.authenticate(authenticationToken)
|
||||
}
|
||||
|
||||
override fun afterSuccessfulAuthentication(userDetails: UserDetails) {
|
||||
updateUserLoginTime(userDetails.id as Long)
|
||||
logger.info("User ${userDetails.id} (${userDetails.username}) has logged in successfully")
|
||||
}
|
||||
|
||||
private fun getLoginRequest(request: HttpServletRequest) =
|
||||
jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java)
|
||||
}
|
|
@ -35,10 +35,7 @@ data class UserDto(
|
|||
val isSystemUser: Boolean = false
|
||||
) : EntityDto {
|
||||
@get:JsonIgnore
|
||||
val authorities
|
||||
get() = permissions
|
||||
.map { it.toAuthority() }
|
||||
.toMutableSet()
|
||||
val fullName = "$firstName $lastName"
|
||||
}
|
||||
|
||||
data class UserSaveDto(
|
||||
|
@ -81,10 +78,22 @@ data class UserUpdateDto(
|
|||
|
||||
data class UserLoginRequestDto(val id: Long, val password: String)
|
||||
|
||||
class UserDetails(val user: UserDto) : SpringUserDetails {
|
||||
override fun getPassword() = user.password
|
||||
override fun getUsername() = user.id.toString()
|
||||
override fun getAuthorities() = user.authorities
|
||||
class UserDetails(
|
||||
val id: Any,
|
||||
private val username: String,
|
||||
private val password: String,
|
||||
val groupId: Long?,
|
||||
val permissions: Collection<Permission>
|
||||
) : SpringUserDetails {
|
||||
constructor(user: UserDto) : this(user.id, user.fullName, user.password, user.group?.id, user.permissions)
|
||||
|
||||
override fun getUsername() = username
|
||||
override fun getPassword() = password
|
||||
|
||||
@JsonIgnore
|
||||
override fun getAuthorities() = permissions
|
||||
.map { it.toAuthority() }
|
||||
.toMutableList()
|
||||
|
||||
override fun isAccountNonExpired() = true
|
||||
override fun isAccountNonLocked() = true
|
||||
|
|
|
@ -2,7 +2,6 @@ package dev.fyloz.colorrecipesexplorer.logic.account
|
|||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||
import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException
|
||||
|
@ -34,7 +33,7 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic)
|
|||
override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id))
|
||||
|
||||
override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto {
|
||||
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
|
||||
val defaultGroupCookie = WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN)
|
||||
?: throw NoDefaultGroupException()
|
||||
val defaultGroupUser = userLogic.getById(
|
||||
defaultGroupCookie.value.toLong(),
|
||||
|
@ -48,7 +47,7 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic)
|
|||
val defaultGroupUser = userLogic.getDefaultGroupUser(getById(id))
|
||||
response.addHeader(
|
||||
"Set-Cookie",
|
||||
"$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict"
|
||||
"${Constants.CookieNames.GROUP_TOKEN}=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import java.util.UUID
|
|||
interface GroupTokenLogic {
|
||||
fun getAll(): Collection<GroupTokenDto>
|
||||
fun getById(id: String): GroupTokenDto
|
||||
fun getById(id: UUID): GroupTokenDto
|
||||
fun save(dto: GroupTokenSaveDto): GroupTokenDto
|
||||
fun deleteById(id: String)
|
||||
}
|
||||
|
@ -25,8 +26,10 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val
|
|||
|
||||
override fun getAll() = service.getAll()
|
||||
|
||||
override fun getById(id: String) =
|
||||
service.getById(UUID.fromString(id)) ?: throw notFoundException(value = id)
|
||||
override fun getById(id: String) = getById(UUID.fromString(id))
|
||||
|
||||
override fun getById(id: UUID) =
|
||||
service.getById(id) ?: throw notFoundException(value = id)
|
||||
|
||||
override fun save(dto: GroupTokenSaveDto): GroupTokenDto {
|
||||
throwIfNameAlreadyExists(dto.name)
|
||||
|
|
|
@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
|||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||
import dev.fyloz.colorrecipesexplorer.utils.base64encode
|
||||
import dev.fyloz.colorrecipesexplorer.utils.toDate
|
||||
import io.jsonwebtoken.Jwts
|
||||
|
@ -20,11 +19,8 @@ interface JwtLogic {
|
|||
/** Build a JWT token for the given [userDetails]. */
|
||||
fun buildJwt(userDetails: UserDetails): String
|
||||
|
||||
/** Build a JWT token for the given [user]. */
|
||||
fun buildJwt(user: UserDto): String
|
||||
|
||||
/** Parses a user from the given [jwt] token. */
|
||||
fun parseJwt(jwt: String): UserDto
|
||||
fun parseJwt(jwt: String): UserDetails
|
||||
}
|
||||
|
||||
@Service
|
||||
|
@ -49,17 +45,14 @@ class DefaultJwtLogic(
|
|||
.build()
|
||||
}
|
||||
|
||||
override fun buildJwt(userDetails: UserDetails) =
|
||||
buildJwt(userDetails.user)
|
||||
|
||||
override fun buildJwt(user: UserDto): String =
|
||||
override fun buildJwt(userDetails: UserDetails): String =
|
||||
jwtBuilder
|
||||
.setSubject(user.id.toString())
|
||||
.setSubject(userDetails.id.toString())
|
||||
.setExpiration(getCurrentExpirationDate())
|
||||
.claim(jwtClaimUser, user.serialize())
|
||||
.claim(jwtClaimUser, userDetails.serialize())
|
||||
.compact()
|
||||
|
||||
override fun parseJwt(jwt: String): UserDto =
|
||||
override fun parseJwt(jwt: String): UserDetails =
|
||||
with(
|
||||
jwtParser.parseClaimsJws(jwt)
|
||||
.body.get(jwtClaimUser, String::class.java)
|
||||
|
@ -72,6 +65,6 @@ class DefaultJwtLogic(
|
|||
.plusSeconds(securityProperties.jwtDuration)
|
||||
.toDate()
|
||||
|
||||
private fun UserDto.serialize(): String =
|
||||
private fun UserDetails.serialize(): String =
|
||||
objectMapper.writeValueAsString(this)
|
||||
}
|
||||
|
|
|
@ -2,8 +2,7 @@ package dev.fyloz.colorrecipesexplorer.logic.account
|
|||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens
|
||||
import dev.fyloz.colorrecipesexplorer.config.security.filters.blacklistedJwtTokens
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto
|
||||
|
@ -139,7 +138,7 @@ class DefaultUserLogic(
|
|||
}
|
||||
|
||||
override fun logout(request: HttpServletRequest) {
|
||||
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
|
||||
val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION)
|
||||
if (authorizationCookie != null) {
|
||||
val authorizationToken = authorizationCookie.value
|
||||
if (authorizationToken != null && authorizationToken.startsWith("Bearer")) {
|
||||
|
|
|
@ -2,19 +2,16 @@ package dev.fyloz.colorrecipesexplorer.rest.account
|
|||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeAdmin
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic
|
||||
import dev.fyloz.colorrecipesexplorer.rest.created
|
||||
import dev.fyloz.colorrecipesexplorer.rest.noContent
|
||||
import dev.fyloz.colorrecipesexplorer.rest.ok
|
||||
import dev.fyloz.colorrecipesexplorer.utils.addCookie
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
import javax.validation.Valid
|
||||
|
||||
@RestController
|
||||
|
@ -28,13 +25,38 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) {
|
|||
@GetMapping("{id}")
|
||||
fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id))
|
||||
|
||||
@PostMapping
|
||||
fun save(@RequestBody @Valid dto: GroupTokenSaveDto) = with(groupTokenLogic.save(dto)) {
|
||||
created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id)
|
||||
@GetMapping("{id}/cookie")
|
||||
fun addCookieForId(@PathVariable id: String, response: HttpServletResponse) {
|
||||
val groupToken = groupTokenLogic.getById(id)
|
||||
addGroupTokenCookie(response, groupToken)
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
fun save(@RequestBody @Valid dto: GroupTokenSaveDto, response: HttpServletResponse) =
|
||||
with(groupTokenLogic.save(dto)) {
|
||||
addGroupTokenCookie(response, this)
|
||||
created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id)
|
||||
}
|
||||
|
||||
@DeleteMapping("{id}")
|
||||
fun deleteById(@PathVariable id: String) = noContent {
|
||||
groupTokenLogic.deleteById(id)
|
||||
}
|
||||
|
||||
private fun addGroupTokenCookie(response: HttpServletResponse, groupToken: GroupTokenDto) {
|
||||
response.addCookie(Constants.CookieNames.GROUP_TOKEN, groupToken.id.toString()) {
|
||||
httpOnly = GROUP_TOKEN_COOKIE_HTTP_ONLY
|
||||
sameSite = GROUP_TOKEN_COOKIE_SAME_SITE
|
||||
secure = !Constants.DEBUG_MODE
|
||||
maxAge = GROUP_TOKEN_COOKIE_MAX_AGE
|
||||
path = GROUP_TOKEN_COOKIE_PATH
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GROUP_TOKEN_COOKIE_HTTP_ONLY = true
|
||||
private const val GROUP_TOKEN_COOKIE_SAME_SITE = true
|
||||
private const val GROUP_TOKEN_COOKIE_MAX_AGE = Long.MAX_VALUE // This cookie should never expire
|
||||
private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.GROUP_LOGIN
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,31 +1,40 @@
|
|||
package dev.fyloz.colorrecipesexplorer.utils
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
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 CookieBuilderOptions(
|
||||
/** HTTP Only cookies cannot be access by Javascript clients. */
|
||||
var httpOnly: Boolean = defaultCookieHttpOnly,
|
||||
var httpOnly: Boolean = DEFAULT_HTTP_ONLY,
|
||||
|
||||
/** SameSite cookies are only sent in requests to their origin location. */
|
||||
var sameSite: Boolean = defaultCookieSameSite,
|
||||
var sameSite: Boolean = DEFAULT_SAME_SITE,
|
||||
|
||||
/** Secure cookies are only sent in HTTPS requests. */
|
||||
var secure: Boolean = defaultCookieSecure,
|
||||
var secure: Boolean = DEFAULT_SECURE,
|
||||
|
||||
/** Cookie's maximum age in seconds. */
|
||||
var maxAge: Long = defaultCookieMaxAge
|
||||
)
|
||||
var maxAge: Long = DEFAULT_MAX_AGE,
|
||||
|
||||
/** The path for which the cookie will be sent. */
|
||||
var path: String = DEFAULT_PATH
|
||||
) {
|
||||
companion object {
|
||||
private const val DEFAULT_MAX_AGE = 3600L
|
||||
private const val DEFAULT_HTTP_ONLY = true
|
||||
private const val DEFAULT_SAME_SITE = true
|
||||
private const val DEFAULT_SECURE = true
|
||||
private const val DEFAULT_PATH = Constants.ControllerPaths.BASE_PATH
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CookieBuilderOption(val optionName: String) {
|
||||
HTTP_ONLY("HttpOnly"),
|
||||
SAME_SITE("SameSite"),
|
||||
SECURE("Secure"),
|
||||
MAX_AGE("Max-Age")
|
||||
MAX_AGE("Max-Age"),
|
||||
PATH("Path")
|
||||
}
|
||||
|
||||
fun HttpServletResponse.addCookie(name: String, value: String, optionsBuilder: CookieBuilderOptions.() -> Unit) {
|
||||
|
@ -50,6 +59,7 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild
|
|||
addBoolOption(CookieBuilderOption.SAME_SITE, options.sameSite)
|
||||
addBoolOption(CookieBuilderOption.SECURE, options.secure)
|
||||
addOption(CookieBuilderOption.MAX_AGE, options.maxAge)
|
||||
addOption(CookieBuilderOption.PATH, options.path)
|
||||
|
||||
return cookie.toString()
|
||||
}
|
||||
}
|
|
@ -31,3 +31,5 @@ spring.jackson.default-property-inclusion=non_null
|
|||
spring.profiles.active=@spring.profiles.active@
|
||||
|
||||
spring.sql.init.continue-on-error=true
|
||||
|
||||
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration
|
|
@ -1,6 +1,7 @@
|
|||
package dev.fyloz.colorrecipesexplorer.logic
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.*
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||
import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic
|
||||
import dev.fyloz.colorrecipesexplorer.service.RecipeService
|
||||
|
|
Loading…
Reference in New Issue