2024. 2. 5. 20:23ㆍTIL
코드 개선과제 2월5일차를 진행해보려 했지만 생각보다 어려워서 일단은 보류했다.
오늘부로 JPA 심화 강의가 지급돼서 미리 계획했던 인증/인가부분을 공부를할지 JPA 심화강의를 들어볼지 생각을 좀 해보았다.
바로 새로운 강의를 들을수도 있지만 너무 새로운것들만 계속 익히면 기존에 배운것도 살짝 뒤죽박죽 될것 같아서 일단 인증/인가 부분을 좀더 공부하기로 했다.
먼저 인증/인가 강의를 들으면서 어떤식으로 진행이 되는지 순서를 세워보고, 그 대략적인 순서를 보고 따라 코드작성을 해볼 예정이다.
우선 오늘은 전체적인 흐름을 한눈에 볼수있게 부연설명보다는 간략하게 적을 예정
1. 사전 설정
implementation("org.springframework.boot:spring-boot-starter-security") //스프링시큐리티 추가
implementation("io.jsonwebtoken:jjwt-api:0.12.3") //시큐리티로 jwt이용(jwt라이브러리추가)
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
빌드.그래들에 위 문구 추가
logging:
level:
org:
hibernate:
SQL: debug
orm:
jdbc:
bind: trace
springframework: # 추가!
security: debug # 추가!
application.yml에 추가
인프라 하위패키지 security 패키지 생성후 SecurityConfig 클래스 추가
SecurityConfig
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.httpBasic { it.disable() } // BasicAuthenticationFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter 제외
.formLogin { it.disable() } // UsernamePassworedAuthenticationFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter 제외
.csrf { it.disable() } // CsrfFilter 제외
.build()
}
}
이렇게하면 swagger에 접속시에 뜨는 로그인화면도 해제된 걸 볼수있다!
2. JWT 관련 기능 구현하기
application.yml 에 추가
auth:
jwt:
issuer: team.sparta.com
secret: PO4c8z41Hia5gJG3oeuFJMRYBB4Ws4aZ
accessTokenExpirationHour: 168
JwtPlugin 클래스 작성
package com.teamsparta.courseregistration.infra.security.jwt
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.time.Instant
import java.util.*
@Component
class JwtPlugin(
@Value("\${auth.jwt.issuer}") private val issuer: String,
@Value("\${auth.jwt.secret}") private val secret: String,
@Value("\${auth.jwt.accessTokenExpirationHour}") private val accessTokenExpirationHour: Long,
) {
fun validateToken(jwt: String): Result<Jws<Claims>> {
return kotlin.runCatching {
val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))
Jwts.parser().verifyWith(key).build().parseSignedClaims(jwt)
}
}
fun generateAccessToken(subject: String, role: String): String {
return generateToken(subject, role, Duration.ofHours(accessTokenExpirationHour))
}
private fun generateToken(subject: String, role: String, expirationPeriod: Duration): String {
val claims: Claims = Jwts.claims().add(mapOf("role" to role)).build()
val now = Instant.now()
val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))
return Jwts.builder()
.subject(subject)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(expirationPeriod)))
.claims(claims)
.signWith(key)
.compact()
}
}
3. 로그인 구현하기
security 하위에
PasswordEncoderConfig 작성
package com.teamsparta.courseregistration.infra.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class PasswordEncoderConfig {
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
}
이후에 유저서비스임플에 패스워드엔코더를 적용해주면 된다.
return userRepository.save(User(
email = request.email,
password = passwordEncoder.encode(request.password), // 암호화!
profile = Profile(
nickname = request.nickname
),
role = when (request.role) {
"STUDENT" -> UserRole.STUDENT
"TUTOR" -> UserRole.TUTOR
else -> throw IllegalArgumentException("Invalid role")
}
)).toResponse()
이어서
LoginRequest, LoginResponse, UserController, Service, Repository, Exception, ServiceImpl까지 생성 및 수정
4. JWT 인증 구현하기
JWT 패키지 하위에
JwtAuthenticationFilter 클래스 작성
package com.teamsparta.courseregistration.infra.security.jwt
import com.teamsparta.courseregistration.infra.security.jwt.JwtPlugin
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
class JwtAuthenticationFilter(
private val jwtPlugin: JwtPlugin
) : OncePerRequestFilter() {
companion object {
// Authorization Header로 부터 JWT를 획득하기 위한 정규식
private val BEARER_PATTERN = Regex("^Bearer (.+?)$")
}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val jwt = request.getBearerToken()
if (jwt != null) {
jwtPlugin.validateToken(jwt)
.onSuccess {
// JWT로 부터 정보 획득
val userId = it.payload.subject.toLong()
val role = it.payload.get("role", String::class.java)
val email = it.payload.get("email", String::class.java)
// TODO: Authetication 구현체 SecurityContext에 저장
}
}
// FilterChain 계속 진행
filterChain.doFilter(request, response)
}
private fun HttpServletRequest.getBearerToken(): String? {
val headerValue = this.getHeader(HttpHeaders.AUTHORIZATION) ?: return null
return BEARER_PATTERN.find(headerValue)?.groupValues?.get(1)
}
}
이렇게 토큰을 획득 이후 만들어 놓은 JwtPlugin으로 토큰을 검증
security 하위에
UserPrincipal 클래스 작성
package com.teamsparta.courseregistration.infra.security
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
data class UserPrincipal(
val id: Long,
val email: String,
val authorities: Collection<GrantedAuthority>
) {
constructor(id: Long, email: String, roles: Set<String>) : this(
id,
email,
roles.map { SimpleGrantedAuthority("ROLE_$it" ) })
}
jwt하위에
JwtAuthenticationToken 클래스 작성
package com.teamsparta.courseregistration.infra.security.jwt
import com.teamsparta.courseregistration.infra.security.UserPrincipal
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.web.authentication.WebAuthenticationDetails
import java.io.Serializable
class JwtAuthenticationToken(
private val principal: UserPrincipal,
// 요청한 Address 정보, sessionId등을 담음 (로깅 용도)
details: WebAuthenticationDetails,
) : AbstractAuthenticationToken(principal.authorities), Serializable {
init {
// JWT 검증이 됐을시에 바로 생성할 예정이므로, 생성시 authenticated를 true로 설정
super.setAuthenticated(true)
super.setDetails(details)
}
override fun getPrincipal() = principal
override fun getCredentials() = null
override fun isAuthenticated(): Boolean {
return true
}
}
이후 Filter도 수정
package com.teamsparta.courseregistration.infra.security.jwt
import com.teamsparta.courseregistration.infra.security.UserPrincipal
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
class JwtAuthenticationFilter(
private val jwtPlugin: JwtPlugin
) : OncePerRequestFilter() {
companion object {
private val BEARER_PATTERN = Regex("^Bearer (.+?)$")
}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val jwt = request.getBearerToken()
if (jwt != null) {
jwtPlugin.validateToken(jwt)
.onSuccess {
val userId = it.payload.subject.toLong()
val role = it.payload.get("role", String::class.java)
val email = it.payload.get("email", String::class.java)
val principal = UserPrincipal(
id = userId,
email = email,
roles = setOf(role)
)
// Authentication 구현체 생성
val authentication = JwtAuthenticationToken(
principal = principal,
// request로 부터 요청 상세정보 생성
details = WebAuthenticationDetailsSource().buildDetails(request)
)
// SecurityContext에 authentication 객체 저장
SecurityContextHolder.getContext().authentication = authentication
}
}
filterChain.doFilter(request, response)
}
private fun HttpServletRequest.getBearerToken(): String? {
val headerValue = this.getHeader(HttpHeaders.AUTHORIZATION) ?: return null
return BEARER_PATTERN.find(headerValue)?.groupValues?.get(1)
}
}
마지막으로 작성한 Filter를 Filter채인에 이용하기 위해 SecurityConfig수정
package com.teamsparta.courseregistration.infra.security
import com.teamsparta.courseregistration.infra.security.jwt.JwtAuthenticationFilter
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.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.httpBasic { it.disable() }
.formLogin { it.disable() }
.csrf { it.disable() }
.authorizeHttpRequests {
it.requestMatchers(
"/login",
"/signup",
"/swagger-ui/**",
"/v3/api-docs/**",
).permitAll()
// 위 URI를 제외하곤 모두 인증이 되어야 함.
.anyRequest().authenticated()
}
// 기존 UsernamePasswordAuthenticationFilter 가 존재하던 자리에 JwtAuthenticationFilter 적용
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.build()
}
}
5. 인증 테스트 및 예외처리
여기까지하면 구현은 완료 됐지만 우리가 테스트하려면 추가적인 설정이 필요하다.
SwaggerConfig
package com.teamsparta.courseregistration.infra.swagger
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class SwaggerConfig {
@Bean
fun openAPI(): OpenAPI {
return OpenAPI()
.addSecurityItem(
SecurityRequirement().addList("Bearer Authentication")
)
.components(
Components().addSecuritySchemes(
"Bearer Authentication",
SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("Bearer")
.bearerFormat("JWT")
.`in`(SecurityScheme.In.HEADER)
.name("Authorization")
)
)
.info(
Info()
.title("Course API")
.description("Course API schema")
.version("1.0.0")
)
}
}
이후엔 인증 예외가 발생시 403 대신 401을 응답할수있도록 CustomAuthenticationEntryPoint 클래스 생성
package com.teamsparta.courseregistration.infra.security
import com.fasterxml.jackson.databind.ObjectMapper
import com.teamsparta.courseregistration.domain.exception.dto.ErrorResponse
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
@Component
class CustomAuthenticationEntrypoint: AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
val objectMapper = ObjectMapper()
val jsonString = objectMapper.writeValueAsString(ErrorResponse("JWT verification failed"))
response.writer.write(jsonString)
}
}
이후 SecurityConfig 수정
package com.teamsparta.courseregistration.infra.security
import com.teamsparta.courseregistration.infra.security.jwt.JwtAuthenticationFilter
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.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val authenticationEntrypoint: AuthenticationEntryPoint
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.httpBasic { it.disable() }
.formLogin { it.disable() }
.csrf { it.disable() }
.authorizeHttpRequests {
it.requestMatchers(
"/login",
"/signup",
"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
// 예외처리
.exceptionHandling{
it.authenticationEntryPoint(authenticationEntrypoint)
}
.build()
}
}
이후엔는 추가적으로 RBAC를 구현할수도 있다.
요청 URI별로 권한을 분리
위 SecurityConfig에 어노테이션을 하나 추가해준다
@EnableMethodSecurity
이후엔 fun filterChain 메소드를 살짝 수정해준다.
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.httpBasic { it.disable() }
.formLogin { it.disable() }
.csrf { it.disable() }
.authorizeHttpRequests {
it.requestMatchers(
"/login",
"/signup",
"/swagger-ui/**",
"/v3/api-docs/**",
).permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // 이렇게요!
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling{
it.authenticationEntryPoint(authenticationEntrypoint)
}
.build()
}
}
이후엔 요청 URI마다 새로운 어노테이션을 달아서 권한을 줄수있다.
@PreAuthorize("#user.name == principal.name")
@PreAuthorize("hasRole('ADMIN') or hasRole('STUDENT')")
위에서 인증예외 처리를 했다면 인가예외 처리도 해보자!
security 하위에 CustomAccessDenieHandler클래스를 작성해준다.
package com.teamsparta.courseregistration.infra.security
import com.fasterxml.jackson.databind.ObjectMapper
import com.teamsparta.courseregistration.domain.exception.dto.ErrorResponse
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.stereotype.Component
@Component
class CustomAccessDeniedHandler: AccessDeniedHandler {
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
response.status = HttpServletResponse.SC_FORBIDDEN
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
val objectMapper = ObjectMapper()
val jsonString = objectMapper.writeValueAsString(ErrorResponse("No permission to run API"))
response.writer.write(jsonString)
}
}
이후 또 SecurityConfig 수정
package com.teamsparta.courseregistration.infra.security
import com.teamsparta.courseregistration.infra.security.jwt.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val authenticationEntrypoint: AuthenticationEntryPoint,
private val accessDeniedHandler: AccessDeniedHandler
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.httpBasic { it.disable() }
.formLogin { it.disable() }
.csrf { it.disable() }
.authorizeHttpRequests {
it.requestMatchers(
"/login",
"/signup",
"/swagger-ui/**",
"/v3/api-docs/**",
).permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling{
it.authenticationEntryPoint(authenticationEntrypoint)
it.accessDeniedHandler(accessDeniedHandler) // 추가!!
}
.build()
}
}
오늘의 한마디 : 이제 위에다 정리한 것을 그저 따라하기보단 왜 이부분에선 이런 코드를 작성한 것일까 하는 의문을 가지고 하다보면 쉽게 늘 수 있을것 같다.
'TIL' 카테고리의 다른 글
20240207 (수) 개인 주특기 플러스 7일차 (0) | 2024.02.07 |
---|---|
20240206 (화) 개인 주특기 플러스 6일차 (1) | 2024.02.06 |
20240202 (금) 개인 주특기 플러스 주차 4일차 (0) | 2024.02.02 |
20240201 (목) 개인 주특기 플러스 주차 3일차 (0) | 2024.02.01 |
20240131 (수) 개인 주특기 플러스 주차 2일차 (0) | 2024.01.31 |