개발을 처음 시작 할 때는 보안과 로그인의 관계에 대해 아무것도 아는게 없어서 그냥 DB에 있는 아이디와 비밀번호만 일치하면 되는거라 생각했다.
그런데 실무에서의 로그인은 매우 복잡한 구조로 이루어져있고, 회원정보를 관리해야 하다 보니
보안에 신경 쓸 수 밖에 없다.
우리 회사의 서비스는 스프링 시큐리티와 JWT를 이용해 토큰인증 방식으로 로그인 및 사용자 관리를 하는데, 코드가 보이는 대로 정리해보았다.
~Config는 JPA설정과 같이 무언가를 설정할 때 사용하는 클래스이다.
여기서는 JWT 뿐만 아니라 CORS등 API를 호출하는 모든 통신에 보안장치를 걸어준다.
/**
* 스프링 시큐리티 설정
*/
@EnableWebSecurity
@EnableConfigurationProperties(JwtProperties::class)
class SecurityConfig(
private val jwtProcessor: JwtProcessor,
private val userRepository: UserRepository,
) : WebSecurityConfigurerAdapter() {
/**
* 규칙설정
*/
override fun configure(http: HttpSecurity) {
http
.cors().configurationSource(corsConfigurationSource())
.and()
.csrf()
.disable()
.addFilterBefore(JwtAuthFilter(jwtProcessor), UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling()
.authenticationEntryPoint(JwtAuthenticationEntryPoint())
.accessDeniedHandler(CustomAccessDeniedHandler())
.and()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers(HttpMethod.GET, "/").permitAll()
.antMatchers(HttpMethod.POST, *signAllowedList()).permitAll()
...(생략)
.antMatchers(*sysAdminAllowedList()).hasAnyRole("SYS_ADMIN")
.anyRequest().authenticated()
.and()
.headers()
.addHeaderWriter(XFrameOptionsHeaderWriter(XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
}
class JwtAuthenticationEntryPoint() : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest?,
response: HttpServletResponse?,
authException: AuthenticationException?
) {
response?.sendError(HttpServletResponse.SC_UNAUTHORIZED, MessageUtil.getMessage("UNAUTHENTICATED_USER"))
}
}
/**
* 시큐리티 인가
*/
class CustomAccessDeniedHandler() : AccessDeniedHandler {
override fun handle(
request: HttpServletRequest?,
response: HttpServletResponse?,
accessDeniedException: AccessDeniedException?
) {
response?.sendError(HttpServletResponse.SC_FORBIDDEN,MessageUtil.getMessage("UNAUTHORIZED_ACCESS"))
}
}
@Bean
fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
@Bean
fun corsConfigurationSource(): CorsConfigurationSource? {
val configuration = CorsConfiguration()
configuration.addAllowedOriginPattern("http://localhost:3000")
//configuration.addAllowedOriginPattern("https://*.kr") //TODO: 배포시 추가
configuration.addAllowedMethod("*")
configuration.addAllowedHeader("*")
configuration.allowCredentials = true
configuration.maxAge = 3600L
configuration.exposedHeaders = listOf("Content-Disposition")
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
}
/**
* 로그인 인증처리
*/
override fun configure(auth: AuthenticationManagerBuilder) {
auth
.userDetailsService(UserDetailsServiceImpl(userRepository))
.passwordEncoder(passwordEncoder())
}
@Bean
override fun authenticationManagerBean(): AuthenticationManager {
return super.authenticationManagerBean()
}
private fun signAllowedList(): Array<String> {
return arrayOf(
"/api/sign-up",
"/api/login"
)
}
}
여기까지는 Security 설정의 일부이다. 하나씩 코드를 살펴보자
http
.cors().configurationSource(corsConfigurationSource())
.and()
.csrf()
.disable()
여기까지는 기본적인 웹 설정이라 넘어간다. 이 다음 행의
.addFilterBefore(JwtAuthFilter(jwtProcessor), UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling()
.authenticationEntryPoint(JwtAuthenticationEntryPoint())
.accessDeniedHandler(CustomAccessDeniedHandler())
에서 addFilterBefore가요청을 처리하기 전 인터셉터의 preHandle 다음으로 실행된다.
(preHandle과 postHandle에 추가 로직이 있었는데 postHandle까지 들어가지 않았다.)
JWT는 보안정보가 담긴 객체를 암호화 <-> 복호화 하면서 이 서버 통신이 유효한 통신인지 검사하는데,
통신이 들어온 다음 유효한지 필터링 하는 부분이다.
class JwtAuthFilter(private val jwtProcessor: JwtProcessor) :
OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val jwtFromRequest = getJwtFromRequest(request)
try {
if (!jwtFromRequest.isNullOrBlank()) {
SecurityContextHolder.getContext().authentication =
jwtProcessor.extractAuthentication(jwtFromRequest) // SecurityContext 에 Authentication 객체를 저장합니다.
}
} catch (e: Exception) {
SecurityContextHolder.clearContext()
}
filterChain.doFilter(request, response)
}
private val BEARER_PREFIX = "Bearer "
private fun getJwtFromRequest(request: HttpServletRequest): String? {
val bearerToken = request.getHeader("Authorization")
return if (!bearerToken.isNullOrBlank() && bearerToken.startsWith(BEARER_PREFIX, true)) {
bearerToken.substring(BEARER_PREFIX.length)
} else null
}
// override fun shouldNotFilter(request: HttpServletRequest): Boolean {
// val orRequestMatcher = OrRequestMatcher(
// jwtExemptionList()
// .map { AntPathRequestMatcher(it) }
// .toList()
// )
// return orRequestMatcher.matches(request)
// }
//
// private fun jwtExemptionList(): List<String> {
// return listOf(
// "/api/somthing/**",
// )
// }
JWT필터는 OncePerRequestFilter를 상속받아서, 내장 메서드(doFilterInternal)를 오버라이드 해 재구현 한다.
doFilterInternal의 try구문에서 아래 getJwtFromRequest 함수로 유효한 토큰인지 확인 후, 이상이 없다면 SecurityContext에 Authentication 객체를 저장한다.
shouldNotFilter에는 JWT 토큰을 검사하지 않아도 되는 목록을 작성한다.
Security Config에도 보안장치나 유저의 권한 등을 체크하지 않는 메서드를 작성할 수 있지만,
이 곳에도 스웨거같은 오픈소스나 회원가입 등 JWT를 적용하지 않아야 하는 목록을 작성할 수 있다.
JwtAuthFilter를 거쳐 JWT토큰이 유효하다면 다음은 UsernamePasswordAuthenticationFilter를 작동해 DB에 실제로 이 사람의 아이디와 비밀번호가 존재하는지 확인한다.
커스텀 클래스는 아니고 JWT 보안을 사용한다면 흔히 사용하는 클래스이다.
JwtProcessor는
하게 만드는 클래스이다.
fun extractAuthentication(jwt: String): UsernamePasswordAuthenticationToken {
val jws = parse(jwt)
val body = jws.body
val userId = body[JwtKeys.UID.keyName].toString()
...(추가 JWT Key 작성)
val userDetails = userDetailsServiceImpl.loadUserByUsername(userId) as SignInUser //db에서 id확인
...(추가 보안 로직 작성)
return UsernamePasswordAuthenticationToken(AuthUser(userDetails), jws, userDetails.authorities)
}
private fun parse(token: String): Jws<Claims> {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.base64EncodedSecret)))
.build()
.parseClaimsJws(token)
}
개발자가 정의한 JWTKey객체의 내용으로 문자열을 파싱 -> 검사하는데
enum class JwtKeys(val keyName: String) {
UID("uid"),
...(생략)
ROLES("roles"),
}
ENUM클래스로 작성했다. 아이디와 권한을 파싱해 토큰에 담을 수 있게 한다.
위에 작성한 추가 보안 로직에 이상이 없다면
val userDetails = userDetailsServiceImpl.loadUserByUsername(userId) as SignInUser
userDetailService를 커스텀한 서비스를 통해 유저정보를 추출하고 추가 비즈니스 로직을 작성해 준다.
@Service
class UserDetailsServiceImpl(
private val userRepository: UserRepository,
) : UserDetailsService {
override fun loadUserByUsername(userId: String?): UserDetails {
if (userId.isNullOrEmpty()) throw IllegalArgumentException("로그인 아이디가 비어있습니다.")
val user = userRepository.getByUserId(userId)
return SignInUser(user)
}
}
UserDetails는 Spring Security에서 사용자의 정보를 담는 인터페이스이고 UserDetailService는Spring Security에서 유저의 정보를 가져오는 인터페이스이다.
class SignInUser(
val user: User,
) : UserDetails {
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return user.role().map { SimpleGrantedAuthority(it.getCode()) }.toMutableSet()
}
override fun getPassword(): String {
return user.password()
}
override fun getUsername(): String {
return user.name()
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return !user.locked()
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return user.checkActiveUser()
}
fun name() = user.name()
fun email() = user.email()
fun roles() = user.role()
fun userId() = user.userId
}
UserDetails를 상속받아 오버라이드 한 내장함수의 리턴값에 현재 API통신을 하고 있는 사용자 정보를 담아준다. 이렇게 우리가 정의한 SignInUser는 JWTKey와 같이 또 하나의 보안객체가 되는 셈이다.
마지막으로
return UsernamePasswordAuthenticationToken(AuthUser(userDetails), jws, userDetails.authorities)
에서 UsernamePasswordAuthenticationToken는 AbstractAuthenticationToken을 상속받은 클래스인데 지금까지는 사용자 정보가 담겨있지 않은 상태로 인증했다, 여기서부터는 사용자 정보가 담긴 토큰을 발행해 인증/인가를 할 수 있도록 변경된다.
즉, UsernamePasswordAuthenticationToken은 추후 인증이 끝나고 SecurityContextHolder.getContext()에 등록될 Authentication 객체이다.
마지막은 로그인 후 JWT를 생성하는 클래스를 작성 해 준다. 여기서 만든 JWT가 보안/인증 과정을 걸친다.
@Component
class JwtGenerator(
private val jwtProperties: JwtProperties,
) {
private val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.base64EncodedSecret))
fun generateUserToken(signInUser: SignInUser): String {
return Jwts.builder()
.setSubject("user")
.claim(JwtKeys.UID.keyName, signInUser.username)
...(추가 JWT Key 작성)
.claim(JwtKeys.ROLES.keyName, signInUser.authorities.map { it.authority })
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(expiration(jwtProperties.swaggerTokenDurationHr))
.compact()
}
private fun expiration(hour: Int?): Date = Date(Date().time + (1000 * 60 * 60 * hour!!.toInt()))
위에서 본 signInUser를 받아 jwt key와 비교해 알맞는 값을 할당해 준다.
물론 signInUser와 jwt key가 맞지 않는다면 로그인은 성공 할 지라도 이후 서버통신에서 권한문제나 인가문제가 발생하기 때문에 정확하게 작성 해 준다.
이제 로그인에 지금까지 구현 한 인증/인가 과정을 적용 해 준다.
@Service
@Transactional
class UserLoginService(
private val authManager: AuthenticationManager,
private val jwtGenerator: JwtGenerator,
private val userRepository: UserRepository
) {
@Transactional(noRollbackFor = [BadCredentialsException::class])
fun login(signIn: SignInIn): SignInOut {
val authenticate: Authentication = try {
authManager.authenticate(UsernamePasswordAuthenticationToken(signIn.userId, signIn.password))
} catch (e: InternalAuthenticationServiceException) { // 존재하지 않는 사용자
throw InternalAuthenticationServiceException(MessageUtil.getMessage("USER_NOT_FOUND"))
} catch (e: DisabledException) { // 유효한 회원이 아님
throw DisabledException(MessageUtil.getMessage("LOGIN_FAIL"))
} ...(추가 보안 로직 작성)
val signInUser = authenticate.principal as SignInUser
return SignInOut.from(signInUser, jwtGenerator.generateUserToken(signInUser))
}
서비스단에서는 보안로직을 통해 유효한 사용자인지, 비밀번호를 얼마나 틀렸는지 등등을 체크 해 주고 모든 보안을 통화했다면
data class SignInOut(
val userId: String,
val accessToken: String,
val roles: List<EnumValue>
) {
companion object {
fun from(signInUser: SignInUser, accessToken: String): SignInOut {
return SignInOut(
userId = signInUser.username,
accessToken = accessToken,
roles = signInUser.roles().map { EnumValue(it) },
)
}
}
}
SignInOut이라는 반환객체에 보안정보를 담아 리턴 해 준다.
@Tag(name = "회원 관리 API")
@RequestMapping("/api")
@RestController
class SignController(
private val userCommandService: UserCommandService,
private val userLoginService: UserLoginService,
private val enumMapper: EnumMapper,
) {
@Operation(summary = "로그인")
@PostMapping("/login")
fun login(@Valid @RequestBody signIn: SignInIn, bindingResult: BindingResult): ResponseEntity<SignInOut> {
if (bindingResult.hasErrors()) throw InvalidException(MessageUtil.getMessage("INVALID_USER_INFO"), bindingResult)
return ResponseEntity.ok(userLoginService.login(signIn))
}
}
컨트롤러에서 사용자 정보를 리턴 해 주면 화면에서는 쿠키나 로컬 스토리지에 토큰을 담아 지속적인 API통신을 할 수 있다!
@ConfigurationProperties(prefix = "jwt") //FIXME: YML과 매핑확인
@ConstructorBinding
data class JwtProperties (
val base64EncodedSecret: String, // JWT 생성/파싱에 사용하는 비밀키
val tokenDurationHr: Int? = 24, // 사용자 토큰 유효 시간
val swaggerTokenDurationHr: Int? = 120, // 스웨거 토큰 유효 시간
val generatorEnabled: Boolean = false, // 스웨거에 토큰 생성기 포함 여부
)
스프링 스큐리티 설정(Security Config)에 EnableConfigurationProperties 어노테이션을 보면 JwtProperties::class를 인자로 받고 있다.
위 객체는 토큰에 대한 추가 정보를 담아주는데 비밀키, 토큰 유효시간 등을 작성 해 준다.