Spring JWT 이용 Sign-In/Sign-Out

Kyojun Jin·2024년 6월 13일

Spring

목록 보기
7/12

환경

Spring 3.2.5
Spring Security 6.2.4
Vue ^3.4.21
Gradle

사용자 정의

사용자를 정의한다.

아주 간단한 예시로, 사용자는 pk로 사용되는 user_id와 natural_id 로 사용되는 username을 가지고 있다. 사용자는 여러 권한을 가질 수 있다.

권한 정의

ROLE_SUPERUSER: 개발자
모든 글 읽기/삭제/수정

ROLE_ADMIN: 중간 관리자
모든 글 읽기/삭제

ROLE_USER: 모든 글 읽기
본인 글 수정/삭제

권한에 따라 할 수 있는 일이 다르다.

ROLE_SUPERUSER > ROLE_ADMIN > ROLE_USER 순으로 권한이 크다.
ROLE_SUPERUSER 권한을 가진 사용자는 최소 ROLE_USER 권한이 필요한 작업을 수행할 수 있다.

회원 가입

회원 가입에는 Username, Password, 그리고 사용자의 권한이 필요하다. 권한은 기본값으로 ROLE_USER로 넣는다고 치고, password를 어떻게 암호화 할 것인지가 관건이다.
회원 가입은 간단하다.
사용자 객체를 만들고, 그 객체 양식에 따라 웹에서 Post 요청을 받은 뒤 비밀번호를 암호화 해서 DB에 저장하면 된다.

비밀번호 암호화 알고리즘은 Spring Security에서 제공하는 것을 사용한다.

BCryptPasswordEncoder
Argon2PasswordEncoder
Pbkdf2PasswordEncoder
SCryptPasswordEncoder

이 중 Pbkdf2PasswordEncoder 를 사용하기로 한다.

SecurityConfig에 Bean으로 등록시켜준다.

@Configuration
class SecurityConfig {
    @Bean
    fun pbkdf2PasswordEncoder(): Pbkdf2PasswordEncoder {
        return Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()
    }
}

사용자에 관한 비즈니스 로직을 관리하는 서비스에서 비밀번호를 암호화 후 DB에 저장한다.

@Service
class UserService(private val passwordEncoder: Pbkdf2PasswordEncoder) {
	@Transactional
    fun signUp(user: UserData) {
    	user.password = passwordEncoder.encode(user.password)
        // add user to db
    }
}

로그인

https://velog.io/@jkjan/Spring-Security
https://velog.io/@jkjan/Spring-Security-Authentication

참조

Spring Security 에서 인증할 때 구현해야 할 것들을 알아본다.

스프링 시큐리티의 인증 절차는 다음과 같다.

  1. 사용자가 인증이 필요한 요청 (HttpServletRequest)을 보낸다.
  2. DelegatingFilterProxy가 필터링된 요청을 서버로 보내려고 한다.
    FilterChainProxy가 적절한 SecurityFilterChain을 골라 이 Filter들에게 작업을 위임한다.
  3. 적절한 SecurityFilterChain이 선택되어서 Security Filter 내 작업들을 진행한다.
  4. 그러던 중 AbstractAuthenticationProcessingFilter가 동작한다.
  5. 이 필터는 인증을 요하는 필터, 서브클래스마다의 Authentication 정보를 생성한다.
  6. Authentication이 AuthenticationManager에 입력된다.
  7. AuthenticationManager는 인터페이스로, ProviderManager로 구현할 수 있다. 이는 AuthenticationProvider들의 리스트에게 인증 작업을 위임한다.
  8. AuthenticationProvider가 각각 특정 유형의 인증 작업을 시도한다.
  9. 인증 결과를 전달받는다.
  10. 실패했을 시
    1. SecurityContextHolder 가 지워진다.
    2. RememberMeServices.loginFail가 참조된다. 설정된 적 없으면 아무런 동작하지 않는다.
    3. AuthenticationFailureHandler가 참조된다.
  11. 성공했을 시
    1. SessionAuthenticationStrategy 에 새 로그인을 알린다.
    2. SecurityContextHolder에 Authentication을 등록한다. 나중에 또 요청할 것을 대비해서 SecurityContext를 저장하려면 SecurityContextRepository#saveContext을 호출해야 한다. SecurityContextHolderFilter 참조.
    3. RememberMeServices.loginSuccess이 참조된다.
    4. ApplicationEventPublisher 가 InteractiveAuthenticationSuccessEvent를 Publish 한다.
    5. AuthenticationSuccessHandler가 참조된다.

6 ~ 9 과정을 더 자세히 설명한다.
서버에 요청을 보내면 SecurityFilterChain 내부의 filter들을 거치게 되고, 최종적으로 Authentication 인스턴스가 생성된다. 이는 사용자의 인증 정보이다. 사용자 정보(principal), 암호화 된 비밀번호(credential), 사용자의 권한(authorities)을 저장하고 있다.

Authentication 인스턴스는 Token 객체로 생성할 수 있다.
토큰들은 AbstractAuthenticationToken을 상속하며, 이는 Authentication을 상속한다. AbstractAuthenticationToken의 하위 클래스들은 Authentication의 정보(principal, crendential, authorities)를 지정하는 각기 다른 방법들을 구현한다.
(하위 클래스들 각각의 생성자 메소드로 Authentication 정보를 저장한다.)

다양한 종류의 AbstractAuthenticationToken

AbstractOAuth2TokenAuthenticationToken,
AnonymousAuthenticationToken,
BearerTokenAuthenticationToken,
CasAssertionAuthenticationToken,
CasAuthenticationToken,
CasServiceTicketAuthenticationToken,
OAuth2AuthenticationToken,
OAuth2AuthorizationCodeAuthenticationToken,
OAuth2LoginAuthenticationToken,
PreAuthenticatedAuthenticationToken,
RememberMeAuthenticationToken,
RunAsUserToken,
Saml2Authentication,
Saml2AuthenticationToken,
TestingAuthenticationToken,
UsernamePasswordAuthenticationToken

출처: https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/authentication/AbstractAuthenticationToken.html

이것을 AuthenticaitonManager를 통해서 사용자를 인증한다.
인증한다는 것은 이 객체를 override 하거나 Bean으로 주입받아 사용하는 것이다.

AuthenticationManagerProviderManager를 통해 구현되며 이는 AuthenticationProvider들을 가진다. 여기서 AuthenticationProvider 실제로 인증을 하는 부분이다.

즉 어떤 AbstractAuthenticationTokenAuthentication을 생성하고, 어떤 AuthenticationProvider로 이를 인증할지를 정해야 한다.

AuthenticationProvider 종류 AbstractJaasAuthenticationProvider, AbstractLdapAuthenticationProvider, AbstractUserDetailsAuthenticationProvider, ActiveDirectoryLdapAuthenticationProvider, AnonymousAuthenticationProvider, AuthenticationManagerBeanDefinitionParser.NullAuthenticationProvider, CasAuthenticationProvider, DaoAuthenticationProvider, DefaultJaasAuthenticationProvider, JaasAuthenticationProvider, JwtAuthenticationProvider, LdapAuthenticationProvider, OAuth2AuthorizationCodeAuthenticationProvider, OAuth2LoginAuthenticationProvider, OidcAuthorizationCodeAuthenticationProvider, OpaqueTokenAuthenticationProvider, OpenSaml4AuthenticationProvider, PreAuthenticatedAuthenticationProvider, RememberMeAuthenticationProvider, RunAsImplAuthenticationProvider, TestingAuthenticationProvider 출처: https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/authentication/AuthenticationProvider.html

이 중 공식 문서에서 사용자이름(username)과 비밀번호(password)를 통한 간단한 인증의 경우 UsernamePasswordAuthenticationTokenDaoAuthenticationProvider를 쌍으로 사용할 것을 권한다.

DaoAuthenticationProviderUserDetailService를 사용하여 인증한다. UserDetailsService 클래스의 메소드를 통해 UserDetail이란 클래스 (사용자 정보가 담긴 일종의 데이타 클래스)를 사용하기 위해서다. 또한 PasswordEncoder도 필요로 한다. (https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html)

UserDetails은 사용자의 정보를 담는 클래스이다. JPA를 사용할 경우 사용자의 엔터티가 UserDetails를 상속해야 한다. UserDetails에 필수로 담겨야 하는 정보는 username, password, authorities이다. authoritiesGrantedAuthority 클래스의 리스트이다.

@Entity
class UserData: UserDetails {
	@Id
	var userId: Int = 1
    
	@NaturalId
    var username: String = ""
    var password: String = ""
    var authorities: MutableList<GrantedAuthority> = mutableListOf()
    
    override fun getAuthorities() ..
    override fun getPassword()..
}

UserDetailsServiceUserDetail를 가져오는 비즈니스 로직을 담는 클래스이다. 사용자에 관한 비즈니스 로직이 담긴 서비스 클래스가 이 클래스를 상속하게 한다.

@Service
class UserService(
	private val userRepository: UserRepository
): UserDetailsService {
	override fun loadUserByUsername(username: String?): UserData {
    	return userRepository.findByUsername(username).get()
    }
}

간단한 예제이므로 Entity와 Details, Service와 DetailsService를 합쳤지만 각각을 분리하는 것이 좋을 수도 있다.
사용자 데이터가 많아지면 UserDetails가 DTO로써 작용할 때 오버헤드가 된다. 단순히 username, password, authority들만 전송하면 되는데 그 이외의 데이터들도 전송하기 때문이다.
다만 UserDetails를 다른 클래스로 구현하게 되면 신경 쓸 게 하나 더 늘어난다. 알아서 잘 판단해보아야 할 듯 하다.

UsernamePasswordToken, DaoAuthenticationProvider로 구현

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html#servlet-authentication-unpwd-input

공식 문서에서 AuthenticationManagerSecurityConfig에서 빈으로 등록한다.

@Configuration
class SecurityConfig {
	// ..
    @Bean
    fun authenticationManager(userDetailsService: UserService, passwordEncoder: Pbkdf2PasswordEncoder): AuthenticationManager  {
        val authenticationProvider = DaoAuthenticationProvider()
        authenticationProvider.setUserDetailsService(userDetailsService)
        authenticationProvider.setPasswordEncoder(passwordEncoder)
        val providerManager = ProviderManager(authenticationProvider)
        return providerManager
    }
}

AuthenticationProviderDaoAuthenticationProvider를 사용할 것을 선언한 후 그것이 필요로 하는 UserDetailsServicePasswordEncoder를 지정해줬다. (PasswordEncoder는 이미 Pbkdf2PasswordEncoder로 bean이 등록된 상태이다.)

이후 사용자의 username과 Password가 들어오는 Controller에서 인증을 진행할 수 있다.

@Controller
class UserController {
	// authenticationManager, userService 주입
    
	@PostMapping("/login")
    fun login(username: String, password: String) {
    	val user = userService.loadUserByUsername(username)
    	val token = UsernamePasswordAuthenticationToken(user, password)
        authenticationManager.authenticate(token)
        SecurityContextHolder.getContext().authentication = authentication
    }
}

마지막 줄은 사용자 정보를 세션에 저장하는 코드이다.
이러면 SecurityContext에 현재 사용자의 정보가 저장된다.
그 중 credential(인증에 쓴, 암호화 된 비밀번호)는 지워진 채로 저장된다.

JWT 적용

인증 정보가 인메모리의 SecurityContextHolder 에 저장된다. Session 방식으로 동작하게 된다.
이를 Token 방식을 수정하도록 한다.
아래는 컨트롤러에서 회원 가입, 로그인을 구현한 코드 스니펫이다. 편의상 Service 레이어까지 가지 않았다.

컨트롤러 구현

@RestController
//...

lateinit var authenticationMnager: AuthenticationManager
lateinit var passwordEncoder: PasswordEncoder

fun signUp(
	@Validated
    @RequestBody
    user: UserDTO
): String {
	val encryptedPassword = passwordEncoder(user.password)  // 비밀번호를 암호화한다.
    val newUser = User(user.username, encrtypedPassword) // 새로운 사용자 인스턴스를 만든다.
    userRepository.save(newUser)  // 사용자를 DB에 저장한다.
	val jwt = createJwt(user.name, newUser.authorities)   // 새로운 사용자의 jwt를 생성하고 클라이언트에게 전달한다.
    return jwt
}

fun signIn(
	@Validated
    @RequestBody
    user: UserDTO
): String {
	try {
    	val token = UsernamePasswordAuthenticationToken(user.username, user.password)  // 이름과 비밀번호로 토큰을 생성한다
    	val authentication = authenticationManager.authenticate(token)  // 해당 토큰으로 인증한다.
        val jwt = createJwt(user.name, authentication.authorities)   // 인증에 성공했다면, 사용자의 Jwt를 생성하고 반환한다.
        return jwt
    }
    exception(e: BadCredentialsException) {
    	return ""    // 만약 authenticate 에 실패한다면 BadCredentialsException 이 발생한다.
    }
}

참고로 BadCredentialsException은 사용자의 이름이 DB에 없거나 비밀번호가 틀리거나 항상 발생한다. 일치하는 경우만 아니면 무조건 발생한다. 보안 상의 이유로 일치 외의 경우는 딱히 구분하지 않는 것이다.

JWT의 전달은 ResponseEntity로 정확한 상태 정보를 담아 전달하는 것이 좋다. ResponseCookie 객체와 JWT를 이용해 보안이 강한 쿠키를 만들어 헤더에 넣어 전달하는 것이 권장된다.

필터 구현

회원 가입이나 로그인으로 토큰이 클라이언트에게 반환됐다면 이를 사용하는 방법은 필터링을 하는 것이다. 특정 권한이 요구되는 작업이 있을 때 이를 필터링해야 한다.

Controller에 진입하기 전에 Spring Security 는 많은 필터를 거치게 된다.

SecurityConfig 클래스에 @EnableWebSecurity(debug = true) 을 붙이고 서버에 요청을 날리면 어떤 필터들이 동작하는지 로그에 나온다. 아래는 예시이다.

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  LogoutFilter
  JwtFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]

JWT 토큰을 보고 이 사용자가 특정 권한이 있는지 확인하기 위해선

  1. JWT 토큰을 까보고 나온 사용자 인증 정보 (authentication)을 SecurityContextHolder에 저장하는 필터
  2. SecurityContextHolder 에 저장된 authentication의 authorities가 해당 요청을 수행할 수 있는지 걸러내는 필터가 필요하다.

2번은 맨 마지막 필터인 AuthorizationFilter 가 담당하는데, 권한을 설정하려면 먼저 권한 설정을 url 패턴으로 할 것인지 메소드 단위로 할 것인지를 정해야 한다.

url을 기준으로 하려면 SecurityConfig의 http 단에 적어주면 된다.

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
	http {
    	authorizeHttpRequests {
        	authorize(HttpMethod.POST, "/post", hasRole("USER"))
        }
    }
}

예를 들어 위는 "/post" 경로에 POST 요청을 보내려면 사용자가 ROLE_USER 이상의 권한을 가지고 있는지 검사하겠다는 뜻이다.

메소드 단위로 하려면 @EnableMethodSecurity를 SpringSecurity 에 달아준다.
그 다음 권한 적용이 필요한 메소드에 @PreAuthorize("hasRole('USER')") 과 같이 달아주면 된다.

이렇게 하면 그 메소드는 ROLE_USER 권한이 들어간 사람만 들어갈 수 있게 된다.

ROLE_USER 권한이 있는지 없는지 알아보려면 JwtFilter 를 구현해야 한다.

class JwtFilter(): OncePerRequestFilter() {
	override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
    	val jwt = request.cookies[0]  // 쿠키에서 Jwt를 얻어옴
        val username = getUsername(jwt) // jwt에서 사용자 이름을 파싱
        val authorities = getAuthorities(jwt)  // jwt에서 사용자 권한을 파싱
        val token = UsernamePasswordAuthenticationToken(username, jwt, authorities) // 사용자 인증 정보 생성
        SecurityContextHolder.getContext().authenticaiton = token   // 사용자 등록
    }
}

맨 마지막에 SecurityContextHolder에 사용자 정보를 등록하는데, 이 이유는 요청까지 가는 길의 필터 중 AuthorizationFilter에서 사용해야 돼서 그렇다.

여기서 authorities 에 들어있는 것이랑 사용자가 지정한 요구 권한을 비교하고 이 요청을 실행할지 말지를 정한다.
실행 못할 때는 AccessDeniedException 핸들링을 하면 될 것 같다.
https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html

이 필터를 UsernamePasswordAuthenticationFilter 앞에 등록해준다.

http {
	addFilterBefore<UsernamePasswordAuthenticationFilter>(JwtFilter(jwtUtil))t
}

세션 = STATELESS

세션을 사용하지 않기 떄문에 이를 STATELESS 로 변경해준다.

http {
	sessionManagement {
    	sessionCreationPolicy = SessionCreationPolicy.STATELESS
    }
}

0개의 댓글