Skip to content

Commit

Permalink
[feat #7] 인증/인가 기초 세팅 (#9)
Browse files Browse the repository at this point in the history
* feat : Security Config, 관련 필터 생성

* feat : 토큰 도메인 모델 구현

* feat : Jwt 설정 값 클래스

* feat : 토큰 Port 생성

* feat : 토큰 인프라 어댑터 구현
- Jpa Entity
- Repository 구현

* feat : 토큰 in port 구현

* feat : 예외 처리 세팅
- 핸들링
- 커스텀 예외 클래스

* refactor : 테스트 컨테이너 설정 클래스 패키지 이동

* refactor : Main 클래스 패키지 변경

* chore : jwt 라이브러리 추가, entry 모듈 인프라 모듈 관련 의존성 추가

* feat : Security filter 내 예외 핸들링

* style : 예외 처리 줄바꿈

* refactor : 불필요 get 제거

* refactor : 예외 throw 부분 early return 처리
  • Loading branch information
dlswns2480 authored Jul 7, 2024
1 parent bccf70f commit fa894e9
Show file tree
Hide file tree
Showing 23 changed files with 431 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.pokit.config

import com.pokit.auth.filter.CustomAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
class SecurityConfig(
private val customAuthenticationFilter: CustomAuthenticationFilter,
private val entryPoint: AuthenticationEntryPoint,
) {
companion object {
private val WHITE_LIST = arrayOf("/api/v1/auth/**")
}

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf { it.disable() }
.formLogin { it.disable() }
.logout { it.disable() }
.httpBasic { it.disable() }
.headers { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.anonymous { it.disable() }
.authorizeHttpRequests {
it.requestMatchers(*WHITE_LIST).permitAll()
it.anyRequest().authenticated()
}
.addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) // 인증 처리
.exceptionHandling { it.authenticationEntryPoint(entryPoint) } // JWT 예외 처리
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.pokit.auth.filter

import com.pokit.auth.exception.AuthErrorCode
import com.pokit.auth.port.`in`.TokenProvider
import com.pokit.common.exception.ClientValidationException
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
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.WebAuthenticationDetails
import org.springframework.stereotype.Component
import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter

@Component
class CustomAuthenticationFilter(
private val tokenProvider: TokenProvider,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
try {
val authentication = getAuthentication(request)
authentication?.let {
SecurityContextHolder.getContext().authentication = it
}
} catch (e: Exception) {
request.setAttribute("exception", e)
}

filterChain.doFilter(request, response)
}

private fun getAuthentication(request: HttpServletRequest): Authentication? {
val header = request.getHeader(HttpHeaders.AUTHORIZATION)
if(!StringUtils.hasText(header)) {
throw ClientValidationException(AuthErrorCode.TOKEN_REQUIRED)
}

val token = header.split(" ")[1]
val userId = tokenProvider.getUserId(token)

/** TODO
* tokenProvider 통해서 유저 정보 가져오기
*
*/
val authentication =
UsernamePasswordAuthenticationToken(
null,
null,
null,
)
authentication.details = WebAuthenticationDetails(request)
return authentication
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.pokit.auth.filter

import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
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

@Component
class JwtExceptionHandlerFilter(
@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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.pokit.common.exception

import org.springframework.http.HttpStatus
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class ApiExceptionHandler {
private val notValidMessage = "잘못된 입력 값입니다."
private val notValidCode = "G_001"

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValidationException(e: MethodArgumentNotValidException): ErrorResponse {
var message = notValidMessage
val allErrors = e.bindingResult.allErrors
if (!allErrors.isEmpty()) {
message = allErrors[0].defaultMessage!!
}

return ErrorResponse(message, notValidCode)
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(NotFoundCustomException::class)
fun handleNotFoundException(e: NotFoundCustomException) = ErrorResponse(e.message!!, e.code)

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ClientValidationException::class)
fun handleClientValidationException(e: ClientValidationException) = ErrorResponse(e.message!!, e.code)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.pokit.common.exception

data class ErrorResponse(
val message: String,
val code: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.pokit.out.persistence.auth.impl

import com.pokit.auth.port.out.RefreshTokenRepository
import com.pokit.out.persistence.auth.persist.RefreshTokenJpaEntity
import com.pokit.out.persistence.auth.persist.RefreshTokenJpaRepository
import com.pokit.out.persistence.auth.persist.toDomain
import com.pokit.token.model.RefreshToken
import org.springframework.stereotype.Repository

@Repository
class RefreshTokenAdapter(
private val refreshTokenJpaRepository: RefreshTokenJpaRepository,
) : RefreshTokenRepository {
override fun save(refreshToken: RefreshToken): RefreshToken {
val refreshTokenEntity = RefreshTokenJpaEntity.of(refreshToken)
val savedToken = refreshTokenJpaRepository.save(refreshTokenEntity)
return savedToken.toDomain()
}

override fun findByUserId(userId: Long): RefreshToken? {
val refreshToken = refreshTokenJpaRepository.findByUserId(userId)
return refreshToken?.toDomain()
}

override fun deleteById(id: Long) {
refreshTokenJpaRepository.deleteById(id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.pokit.out.persistence.auth.persist

import com.pokit.token.model.RefreshToken
import jakarta.persistence.*

@Table(name = "REFRESH_TOKEN")
@Entity
class RefreshTokenJpaEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
val id: Long = 0L,
@Column(name = "userId")
val userId: Long,
@Column(name = "token")
val token: String,
) {
companion object {
fun of(refreshToken: RefreshToken) =
RefreshTokenJpaEntity(
userId = refreshToken.userId,
token = refreshToken.token,
)
}
}

fun RefreshTokenJpaEntity.toDomain() = RefreshToken(userId = this.userId, token = this.token)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.pokit.out.persistence.auth.persist

import org.springframework.data.jpa.repository.JpaRepository

interface RefreshTokenJpaRepository : JpaRepository<RefreshTokenJpaEntity, Long> {
fun findByUserId(userId: Long): RefreshTokenJpaEntity?
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.pokit.out.persistence.common.support

import com.pokit.out.persistence.common.config.TestAuditingConfig
import com.pokit.out.persistence.support.TestContainerSupport
import com.pokit.support.TestContainerSupport
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.pokit.support

import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.JdbcDatabaseContainer
import org.testcontainers.containers.MySQLContainer
import org.testcontainers.utility.DockerImageName

abstract class TestContainerSupport {
companion object {
private const val MYSQL_IMAGE = "mysql:8.0"

private const val MYSQL_PORT = 3306

private val MYSQL: JdbcDatabaseContainer<*> =
MySQLContainer<Nothing>(DockerImageName.parse(MYSQL_IMAGE))
.withExposedPorts(MYSQL_PORT)

init {
MYSQL.start()
}

@JvmStatic
@DynamicPropertySource
fun overrideProps(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.driver-class-name") { MYSQL.driverClassName }
registry.add("spring.datasource.url") { MYSQL.jdbcUrl }
registry.add("spring.datasource.username") { MYSQL.username }
registry.add("spring.datasource.password") { MYSQL.password }
}
}
}
3 changes: 3 additions & 0 deletions application/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ dependencies {
// 라이브러리
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework:spring-tx")
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")

// 테스팅
testImplementation("io.mockk:mockk:1.13.7")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pokit.auth.exception

import com.pokit.common.exception.ErrorCode

enum class AuthErrorCode(
override val message: String,
override val code: String,
) : ErrorCode {
TOKEN_EXPIRED("토큰이 만료되었습니다.", "A_000"),
NOT_FOUND_TOKEN("존재하지 않는 토큰입니다.", "A_001"),
INVALID_TOKEN("유효하지 않은 토큰입니다.", "A_003"),
UNSUPPORTED_TOKEN("지원하지 않는 형식의 토큰입니다.", "A_004"),
WRONG_SIGNATURE("JWT 서명이 서버에 산정된 서명과 일치하지 않습니다.", "A_005"),
TOKEN_REQUIRED("토큰이 비어있습니다.", "A_006"),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.pokit.auth.port.`in`

import com.pokit.token.model.Token

interface TokenProvider {
fun createToken(userId: Long): Token

fun reissueToken(refreshToken: String): String

fun deleteRefreshToken(refreshTokenId: Long)

fun getUserId(token: String): Long
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pokit.auth.port.out

import com.pokit.token.model.RefreshToken

interface RefreshTokenRepository {
fun save(refreshToken: RefreshToken): RefreshToken

fun findByUserId(userId: Long): RefreshToken?

fun deleteById(id: Long)
}
Loading

0 comments on commit fa894e9

Please sign in to comment.