Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[KAN-10] security & jwt 세팅 #8

Merged
merged 2 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")

// Jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

// Security
implementation("org.springframework.boot:spring-boot-starter-security")

// Logging
implementation("io.github.microutils:kotlin-logging:1.12.5")

// Redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")

// Validation
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.hibernate.validator:hibernate-validator:6.1.2.Final")

// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("junit", "junit", "4.13.2")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.restaurant.be.common.config

import com.fasterxml.jackson.databind.ObjectMapper
import com.restaurant.be.common.jwt.JwtFilter
import com.restaurant.be.common.jwt.JwtUserRepository
import com.restaurant.be.common.jwt.TokenProvider
import com.restaurant.be.common.redis.RedisRepository
import org.springframework.security.config.annotation.SecurityConfigurerAdapter
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.DefaultSecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

class JwtSecurityConfig(
private val tokenProvider: TokenProvider,
private val jwtUserRepository: JwtUserRepository,
private val redisRepository: RedisRepository,
private val objectMapper: ObjectMapper
) : SecurityConfigurerAdapter<DefaultSecurityFilterChain?, HttpSecurity>() {

override fun configure(http: HttpSecurity) {
val customFilter =
JwtFilter(tokenProvider, jwtUserRepository, redisRepository, objectMapper)
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter::class.java)
}
}
35 changes: 35 additions & 0 deletions src/main/kotlin/com/restaurant/be/common/config/RedisConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.restaurant.be.common.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
import org.springframework.data.redis.serializer.StringRedisSerializer

@Configuration
@EnableRedisRepositories
class RedisConfig {

@Value("\${spring.redis.host}")
private val redisHost: String = ""

@Value("\${spring.redis.port}")
private val redisPort: Int = 0

@Bean
fun redisConnectionFactory(): RedisConnectionFactory {
return LettuceConnectionFactory(redisHost, redisPort)
}

@Bean
fun redisTemplate(): RedisTemplate<*, *>? {
val redisTemplate = RedisTemplate<ByteArray, ByteArray>()
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = StringRedisSerializer()
redisTemplate.setConnectionFactory(redisConnectionFactory())
return redisTemplate
}
}
60 changes: 60 additions & 0 deletions src/main/kotlin/com/restaurant/be/common/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.restaurant.be.common.config

import com.fasterxml.jackson.databind.ObjectMapper
import com.restaurant.be.common.jwt.JwtAuthenticationEntryPoint
import com.restaurant.be.common.jwt.JwtUserRepository
import com.restaurant.be.common.jwt.TokenProvider
import com.restaurant.be.common.redis.RedisRepository
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig(
val tokenProvider: TokenProvider,
val jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint,
val jwtUserRepository: JwtUserRepository,
val redisRepository: RedisRepository,
val objectMapper: ObjectMapper
) : WebSecurityConfigurerAdapter() {

@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}

override fun configure(http: HttpSecurity) {
http
.csrf().disable()
.formLogin().disable().exceptionHandling().and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers(
"/v1/users/email/sign-up",
"/v1/users/email/sign-in",

"/hello",
"/profile",

"/swagger-ui/**",
"/swagger-resources/**",
"/v2/api-docs",
"/webjars/**"
).permitAll().anyRequest().authenticated().and()
.exceptionHandling { it.authenticationEntryPoint(jwtAuthenticationEntryPoint) }
.apply(
JwtSecurityConfig(
tokenProvider,
jwtUserRepository,
redisRepository,
objectMapper
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.restaurant.be.common.converter

import javax.persistence.AttributeConverter
import javax.persistence.Converter

@Converter
class SeparatorConverter : AttributeConverter<List<Any>, String> {
override fun convertToDatabaseColumn(attribute: List<Any>): String {
return attribute.map { it.toString() }.joinToString(",")
}

override fun convertToEntityAttribute(dbData: String): List<Any> {
return dbData.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.bind.support.WebExchangeBindException
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.ServerWebInputException
import java.security.SignatureException

@RestControllerAdvice
class GlobalExceptionHandler {
Expand Down Expand Up @@ -71,6 +72,15 @@ class GlobalExceptionHandler {
return errorResponse
}

@ExceptionHandler(SignatureException::class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
fun handleSignatureException(
e: SignatureException
): CommonResponse<String?> {
val errorResponse = CommonResponse.fail(e.message, e::class.java.simpleName)
return errorResponse
}

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(value = [Exception::class])
fun exception(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,27 @@ sealed class ServerException(
val code: Int,
override val message: String
) : RuntimeException(message)

data class InvalidTokenException(
override val message: String = "유효하지 않은 토큰입니다."
) : ServerException(401, message)

data class InvalidPasswordException(
override val message: String = "패스워드가 일치 하지 않습니다."
) : ServerException(401, message)

data class NotFoundUserEmailException(
override val message: String = "존재 하지 않는 유저 이메일 입니다."
) : ServerException(400, message)

data class WithdrawalUserException(
override val message: String = "탈퇴한 유저 입니다."
) : ServerException(400, message)

data class DuplicateUserEmailException(
override val message: String = "이미 존재 하는 이메일 입니다."
) : ServerException(400, message)

data class DuplicateUserNickNameException(
override val message: String = "이미 존재 하는 닉네임 입니다."
) : ServerException(400, message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.restaurant.be.common.jwt

import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.stereotype.Component
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {

override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException?
) {
response.sendError(HttpServletResponse.SC_FORBIDDEN)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.restaurant.be.common.jwt

import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerExceptionResolver
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@Component
class JwtAuthenticationEntryPoint(
@Qualifier("handlerExceptionResolver")
private val resolver: HandlerExceptionResolver
) : AuthenticationEntryPoint {

override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
resolver.resolveException(request, response, null, request.getAttribute("exception") as Exception)
}
}
67 changes: 67 additions & 0 deletions src/main/kotlin/com/restaurant/be/common/jwt/JwtFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.restaurant.be.common.jwt

import com.fasterxml.jackson.databind.ObjectMapper
import com.restaurant.be.common.redis.RedisRepository
import mu.KotlinLogging
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter
import java.security.SignatureException
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class JwtFilter(
private val tokenProvider: TokenProvider,
private val jwtUserRepository: JwtUserRepository,
private val redisRepository: RedisRepository,
private val objectMapper: ObjectMapper
) : OncePerRequestFilter() {

private val log = KotlinLogging.logger {}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val requestURI = request.requestURI

val accessToken = tokenProvider.resolveToken(request.getHeader(AUTHORIZATION_HEADER))
val refreshToken = tokenProvider.resolveToken(request.getHeader(REFRESH_TOKEN_HEADER))

if (refreshToken == null) {
if (StringUtils.hasText(accessToken) && tokenProvider.validateToken(accessToken)) { // 토큰의 유효성이 검증됐을 경우,
if (jwtUserRepository.validTokenByEmail(tokenProvider.getEmailFromToken(accessToken!!))) {
val authentication: Authentication =
tokenProvider.getAuthentication(accessToken)
SecurityContextHolder.getContext().authentication = authentication
}
} else {
log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI)
request.setAttribute("exception", SignatureException())
}
} else {
tokenProvider.validateToken(refreshToken)

if (accessToken == null) {
log.debug("accessToken 이 존재하지 않습니다., uri: {}", requestURI)
return
}

val newAccessToken = tokenProvider.tokenReissue(accessToken, refreshToken)

val header = response.getHeader(AUTHORIZATION_HEADER)
if (header == null || "" == header) {
response.addHeader(AUTHORIZATION_HEADER, newAccessToken.accessToken)
}
}

filterChain.doFilter(request, response)
}

companion object {
const val AUTHORIZATION_HEADER = "Authorization"
const val REFRESH_TOKEN_HEADER = "Refresh-Token"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.restaurant.be.common.jwt

interface JwtUserRepository {

fun validTokenByEmail(email: String): Boolean
fun userRolesByEmail(email: String): List<String>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.restaurant.be.common.jwt

import com.restaurant.be.common.exception.NotFoundUserEmailException
import com.restaurant.be.common.exception.WithdrawalUserException
import com.restaurant.be.user.repository.UserRepository
import org.springframework.stereotype.Repository

@Repository
class JwtUserRepositoryImpl(
private val userRepository: UserRepository
) : JwtUserRepository {

override fun validTokenByEmail(email: String): Boolean {
val user = userRepository.findByEmail(email) ?: return false
return !user.withdrawal
}

override fun userRolesByEmail(email: String): List<String> {
val user = userRepository.findByEmail(email) ?: throw NotFoundUserEmailException()
if (user.withdrawal) {
throw WithdrawalUserException()
}

return user.roles
}
}
7 changes: 7 additions & 0 deletions src/main/kotlin/com/restaurant/be/common/jwt/Role.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.restaurant.be.common.jwt

enum class Role {
ROLE_USER,
ROLE_ADMIN
;
}
Loading
Loading