#30 Add login from group tokens
continuous-integration/drone/push Build is passing Details

This commit is contained in:
FyloZ 2022-04-28 15:02:01 -04:00
parent a3b49804a5
commit 37b597936b
Signed by: william
GPG Key ID: 835378AE9AF4AE97
17 changed files with 345 additions and 135 deletions

View File

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

View File

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

View File

@ -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'")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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