Skip to content
Open
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
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
}
47 changes: 47 additions & 0 deletions src/main/kotlin/study/common/authority/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//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/post/posting", "/api/member/info").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