Skip to content
10 changes: 10 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
//Validation 사용시 필요
implementation("org.springframework.boot:spring-boot-starter-validation")
//Spring Security 사용시 필요
implementation("org.springframework.boot:spring-boot-starter-security")
//JWT 사용시 필요
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
//암호화 알고리즘 사용시 필요
implementation("org.bouncycastle:bcprov-jdk15on:1.70")
}

noArg{
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/study/StudyApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package study

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

@SpringBootApplication
@EnableJpaAuditing
class StudyApplication

fun main(args: Array<String>) {
Expand Down
42 changes: 42 additions & 0 deletions src/main/kotlin/study/common/annotation/ValidEnum.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//annotation: 사용자 생성 어노테이션
package study.common.annotation

import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import jakarta.validation.Payload
import kotlin.reflect.KClass

//@Target: annotation 이 적용될 위치 선택
//@Retention: 어노테이션을 컴파일된 클래스 파일에 저장할 것인지(SOURCE) 런타임에 반영할 것인지(RUNTIME) 정의
//@MustBeDocumented: API 의 일부분으로 문서화하기 위해 사용
//@Constraint
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Constraint(validatedBy = [ValidEnumValidator::class])
//annotation: 주석처럼 코드에 달아 클래스에 특별한 의미 부여, 기능 주입 ex)@Override
annotation class ValidEnum (
val message: String = "Invalid enum value",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val enumClass: KClass<out Enum<*>>
)

//유효성 검사
class ValidEnumValidator : ConstraintValidator<ValidEnum, Any> {
private lateinit var enumValues: Array<out Enum<*>>

override fun initialize(annotation: ValidEnum) {
enumValues = annotation.enumClass.java.enumConstants
}

//value: 사용자로부터 받은 값
override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean {
if (value == null) {
return true
}
//any: 조건을 만족하는 원소가 1개 이상 존재하면 true
return enumValues.any {it.name == value.toString()}
}
}
38 changes: 38 additions & 0 deletions src/main/kotlin/study/common/authority/JwtAuthenticationFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//JwtAuthenticationFilter: 토큰 정보 검사, Security Context Holder 에 정보 기록
package study.common.authority

import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.util.StringUtils
import org.springframework.web.filter.GenericFilterBean

class JwtAuthenticationFilter (
private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {//GenericFilterBean 상속
override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
val token = resolveToken(request as HttpServletRequest)//access token 정보

//정상 토큰이면 정보 추출 -> SecurityContextHolder 에 기록
if (token != null && jwtTokenProvider.validateToken(token)) {
val authentication = jwtTokenProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication
}

chain?.doFilter(request, response)
}

//request 로부터 Header Authorization 문자를 가져와 bearer 와 맞는지 찾음
//bearer 와 맞으면 key 값만 추출
private fun resolveToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader("Authorization")

return if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
bearerToken.substring(7)
} else {
null
}
}
}
101 changes: 101 additions & 0 deletions src/main/kotlin/study/common/authority/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package study.common.authority

import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import io.jsonwebtoken.security.SecurityException
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import study.common.dto.CustomUser
import java.util.*

const val EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 30//60초 * 30개 -> 30분
//JwtTokenProvider: 토큰 생성, 정보 추출, 검증
@Component
class JwtTokenProvider {
@Value("\${jwt.secret}")
lateinit var secretKey: String

private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))}

/**
* Token 생성
*/
fun createToken(authentication: Authentication): TokenInfo {
val authorities: String = authentication
.authorities
.joinToString(",", transform = GrantedAuthority::getAuthority)

//만료시간 설정
val now = Date()
val accessExpiration = Date(now.time + EXPIRATION_MILLISECONDS)

//Access Token 생성
val accessToken = Jwts
.builder()
.subject(authentication.name)
.claim("auth", authorities)//auth 라는 이름으로 권한을 담음
//토큰 생성시 userId 정보도 기록
.claim("userId", (authentication.principal as CustomUser).userId)
.issuedAt(now)//토큰 발행 시간
.expiration(accessExpiration)//토큰 유효 시간
.signWith(key, Jwts.SIG.HS256)//사용한 알고리즘
.compact()

return TokenInfo("Bearer", accessToken)
}

/**
* Token 정보 추출
*/
fun getAuthentication(token: String): Authentication {//parameter: access token
val claims: Claims = getClaims(token)

//auth 가 없으면 RuntimeException
val auth = claims["auth"] ?: throw RuntimeException("잘못된 토큰입니다.")
val userId = claims["userId"] ?: throw RuntimeException("잘못된 토큰입니다.")

//권한 정보 추출
val authorities: Collection<GrantedAuthority> = (auth as String)
.split(",")
.map {SimpleGrantedAuthority(it)}

val principal: UserDetails =
CustomUser(userId.toString().toLong(),claims.subject, "", authorities)

return UsernamePasswordAuthenticationToken(principal, "", authorities)
}

/**
* Token 검증
*/
fun validateToken(token: String): Boolean {
try {//문제가 없으면 true 반환
getClaims(token)
return true
} catch (e: Exception) {//exception 별 처리
when (e) {
is SecurityException -> {} //Invalid JWT Token
is MalformedJwtException -> {} //Invalid JWT Token
is ExpiredJwtException -> {} //Expired JWT Token
is UnsupportedJwtException -> {} //Unsupported JWT Token
is IllegalArgumentException -> {} //JWT claims string is empty
else -> {}
}
println(e.message)
}
return false
}

private fun getClaims(token: String): Claims =
Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.payload
}
48 changes: 48 additions & 0 deletions src/main/kotlin/study/common/authority/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//SecurityConfig: 인증, 인가 관리
package study.common.authority

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.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
class SecurityConfig (
private val jwtTokenProvider: JwtTokenProvider
) {
@Bean//스프링 컨테이너를 통해 관리되는 객체
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.httpBasic { it.disable() }
.csrf { it.disable() }
//JWT 를 사용하기 때문에 Session 사용 X
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
//권한 관리
//인증되지 않은 사용자만 "/api/member/signup, login" URL 호출 가능
//그 외의 요청은 회원 권한이 있어야 가능
.authorizeHttpRequests {
it.requestMatchers("/api/member/signup", "/api/member/login").anonymous()
.requestMatchers("/api/member/info/**").hasRole("MEMBER")
.requestMatchers("api/post/posting").hasRole("MEMBER")
.anyRequest().permitAll()
}
//뒤 필터를 실행하기 전에 앞 필터를 먼저 실행, 앞 필터가 통과되면 뒤 필터는 실행 X
.addFilterBefore(
JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter::class.java
)

return http.build()
}

//코드 암호화
@Bean
fun passwordEncoder(): PasswordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
8 changes: 8 additions & 0 deletions src/main/kotlin/study/common/authority/TokenInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//authority: 관한 관련 기능 분류
package study.common.authority

//TokenInfo: 로그인 시 토큰 정보를 담아 클라이언트에게 전달
data class TokenInfo (
val grantType: String,//JWT 권한 인증 타입
val accessToken: String,//실제 검증할 토큰
)
10 changes: 10 additions & 0 deletions src/main/kotlin/study/common/dto/BaseResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//dto: 어플리케이션 전반에 공통적으로 사용할 수 있는 DTO
package study.common.dto

import study.common.status.ResultCode

data class BaseResponse<T> (
val resultCode: String = ResultCode.SUCCESS.name,//결과 코드
val data: T? = null,//조회, 처리시 데이터를 담아서 반환해줄 data
val message: String = ResultCode.SUCCESS.msg,//처리 메세지
)
11 changes: 11 additions & 0 deletions src/main/kotlin/study/common/dto/CustomUser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package study.common.dto

import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.User

class CustomUser (
val userId: Long,
userName: String,
password: String,
authorities: Collection<GrantedAuthority>
) : User(userName, password, authorities)
50 changes: 50 additions & 0 deletions src/main/kotlin/study/common/exception/CustomExceptionHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//exception: 예외 처리 Handler
package study.common.exception

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import study.common.dto.BaseResponse
import study.common.status.ResultCode

@RestControllerAdvice
class CustomExceptionHandler {

//@Valid(DTO의 validation)에서 발생한 exception
@ExceptionHandler(MethodArgumentNotValidException::class)
protected fun methodArgumentNotValidException(ex: MethodArgumentNotValidException) : ResponseEntity<BaseResponse<Map<String, String>>> {
val errors = mutableMapOf<String, String>()
ex.bindingResult.allErrors.forEach { error ->
val fieldName = (error as FieldError).field
val errorMessage = error.defaultMessage
errors[fieldName] = errorMessage ?: "Not Exception Message"
}

return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST)
}

//사용자 생성 exception
@ExceptionHandler(InvalidInputException::class)
protected fun invalidInputException(ex: InvalidInputException) : ResponseEntity<BaseResponse<Map<String, String>>> {
val errors = mapOf(ex.fieldName to (ex.message ?: "Not Exception Message"))
return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST)
}

//로그인 실패 exception
@ExceptionHandler(BadCredentialsException::class)
protected fun badCredentialsException(ex: BadCredentialsException) : ResponseEntity<BaseResponse<Map<String, String>>> {
val errors = mapOf("로그인 실패" to "아이디 혹은 비밀번호를 다시 확인하세요.")
return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST)
}

//그 외의 처리하지 못 한 모든 exception
@ExceptionHandler(Exception::class)
protected fun defaultException(ex: InvalidInputException) : ResponseEntity<BaseResponse<Map<String, String>>> {
val errors = mapOf("미처리 에러" to (ex.message ?: "Not Exception Message"))
return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//exception: 예외 처리
package study.common.exception

//DB 확인 후 발생하는 예외 처리
class InvalidInputException (
val fieldName: String = "",
message: String = "Invalid Input"
) : RuntimeException(message)//RuntimeException 상속
31 changes: 31 additions & 0 deletions src/main/kotlin/study/common/service/CustomUserDetailsService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package study.common.service

import study.member.entity.Member
import study.member.repository.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import study.common.dto.CustomUser

@Service
class CustomUserDetailsService (
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder,
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails =
memberRepository.findByLoginId(username)//고객ID로 찾기
?.let { createUserDetails(it) } //검색되는 정보가 있음 createUserDetails() 호출
?: throw UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.") //검색되는 정보가 없음

private fun createUserDetails(member: Member): UserDetails =
CustomUser(
member.userId!!,
member.loginId,
passwordEncoder.encode(member.password),
member.memberRole!!.map { SimpleGrantedAuthority("ROLE_${it.role}")}
)
}
Loading