Kotlin

Kotlin Springboot Rest API 만들기 5 - jwt token, Spring Security로 인증 인가, 로그인 만들기

pepega 2023. 1. 23. 19:59

 

이전 포스팅에서는

Entity를 토대로 회원가입을 만들어보았습니다.

동시에 i18n을 포함한

Common Response body를 만들었습니다.

 

이번 포스팅에서는

jwt token, SpringSecurity로 로그인 만들기를 할겁니다.

 

전체 코드는 여기에 있습니다.

 

API를 만들고

인터넷에 열어두면 위험 소지가 있습니다.

로그인, 회원가입 이외에

다른 API를 그냥 호출하면 401 오류가 나오게 설정하고

headers에 jwt 기반의 token 을 넣어야만 호출이 되도록 설정할 예정입니다.

 

여기서 사용될 Filters는 아래와 같습니다.

 

A Review of Filters

Spring Security는 공격에 대한 인증, 권한 부여 및 보호 기능을 제공하는 프레임워크입니다.

 

SpringSecurity의 Servlet 지원은 Servlet Filters를 기반으로 합니다.

아래는 단일 HTTP 요청에 대한 그림입니다.

FilterChain

클라이언트는 애플리케이션에 요청을 보내고

컨테이너는 요청 URI 경로를 기반으로

HttpServletRequest를 처리해야 하는 Filter instances와

Servlet을 포함하는 ChainFilter를 생성합니다.

 

최대 하나의 Servlet이 단일 HttpServletRequest, HttpServletResponse를 처리 할 수 있습니다.

Filter는 다운스트림 Filter와 Servlet에만 영향을 미치기 때문에

각 Filter가 호출되는 순서는 매우 중요합니다.

 

SecurityFilterChain

전 글에서 잠깐 사용했던 FilterChain입니다.

FilterChainProxy에서 현재 요청에 대해 호출할

Spring Security Filter instances를 결정하는 데 사용됩니다.

 

일반적으로 Bean이지만

DelegateFilterProxy 대신 FilterChainProxy에 등록됩니다.

 

Spring Security의 모든 Servlet 지원을 위한 starting point를 지원합니다.

Spring Security의 모든 Servlet support 문제를 해결하려면

FilterChainProxy에서 debug point를 추가하는 것이 좋습니다.

 

Multiple SecurityFilterChain

위 그림에서

FilterChainProxy는 사용 할 SecurityFilterChain을 정합니다.

/api/messages/ 라는 URL이 호출되면

SecurityFilterChain0인 /api/* 패턴과 일치하므로 SecurityFilterChain0를 호출합니다.

(SecurityFilterChain과 일치하더라도 SecurityFilterChain0 만 호출합니다.)

 

/messages/ 라는 URL이 호출되면 SecurityFilterChain0 패턴과 일치하지 않으므로

FilterChainProxy는 각 SecurityFilterChain을 시도합니다.

(아마 SecurityFilterChainn 을 호출 할 것 같습니다.)

 

각 SecurityFilterChain은 고유할 수 있고 독립적으로 구성 할 수 있습니다.

간략한 설명은 여기까지입니다!

아래부터 구현을 진행해보도록 하겠습니다.

 

패스워드 암호화하여 저장하기

저번 글에서

회원가입을 진행 할 때

유저의 패스워드를 암호화 하지 않았습니다.

 

암호화 후 다음 순서로 진행하겠습니다.

config package 밑에 security package를 생성합니다.

그 안에 PasswordEncodingConfig 클래스를 생성합니다.

과거에 작성했던 SecurityConfig 클래스도 security package로 이동합니다.

createDelegatingPasswordEncoder 라는 메서드를 사용하여 했으나

메서드를 확인해보니 deprecation이 된 것을 확인 할 수 있었습니다.

 

 

그래서 BCryptPasswordEncoder로 대체하여 사용하기로 했습니다.

Params:
version – the version of bcrypt, can be 2a,2b,2y
strength – the log rounds to use, between 4 and 31

 

Bean을 생성했으니

회원가입 할 때

패스워드를 암호화 하여 insert 하겠습니다.

 

 

테스트 코드를 통해

인코딩이 정상적으로 되었는지 확인해보겠습니다!

 

테스트가 통과되었습니다.

결과를 확인해보니

Input password : 3q4mf9ao8eirghj, 
output password : $2a$12$XucYU21g0Efz6Lc4Tpv56.18mbu/JvJlS3.IPPLNmadbnN3K/v3qy

암호화가 정상적으로 이루어진 것을 확인 할 수 있습니다.

 

회원가입 시 정상적으로 암호화가 된 것을 확인하였으니

이제 로그인을 만들어보겠습니다.

 

로그인 만들기

저장된 계정을 토대로

이메일, 패스워드로 로그인에 성공하면

성공 결과 값jwt token, 토큰 만료 시간을 도출 할 예정입니다.

 

회원가입, 로그인은 외부에서 접근이 가능해야 하기 때문에

SecurityFilterChain에서 permitAll을 걸어두고

이외에는 token으로 호출 할 수 있게 진행 할 예정입니다.

 

signIn service code 만들기

 

dto.sign package 안에

request, response package를 생성하여

request, response dto를 나눕니다.

로그인 할 때 예상 되는 몇 가지 오류가 발생 할 수 있습니다.

1. 계정이 존재하지 않는 경우 (UserNotFoundException)

2. 패스워드가 틀린 경우 (PasswordNotMatchExceptionCustom)

 

예상 되는 오류를 sealed interface Error에 작성합니다.

 

Exception을 handling 할 수 있도록

ExceptionAdvice 클래스에 ExceptionHandler를 추가해줍니다.

 

오류 메시지도 같이 추가해줍니다.

 

로그인 할 때 정보를 받을 request dto를 생성합니다.

 

로그인이 성공했을 때 정보를 출력할 response dto를 생성합니다.

 

SignService 클래스 안에

로그인을 처리 할 수 있는 service 코드를 생성합니다.

 

로그인에 성공할 경우

createToken이라는 메서드를 활용해서

토큰을 발급 후 response body에 출력 할 예정입니다.

 

 

JwtTokenProvider Component 만들기

 

config.security package 안에 JwtTokenProvider 클래스를 생성합니다.

비즈니스 로직이 아니기 때문에

config package 안에 Component annotation을 사용하여 IoC Container에 등록합니다.

 

JwtTokenProvider가 하는 일은 아래와 같습니다.

1. 토큰 생성 및 토큰 유효기간 설정

    -> createToken

2. Headers에 입력된 토큰 가져오기

    -> resolveToken

3. 토큰 유효성 체크

    -> validateToken

4. 토큰 복호화

    -> getUserPrimaryKey

5. 사용자에게 권한 부여

    -> userAuthentication

 

 

우선 gradle에서 의존성을 추가해줍니다.

 

 

createToken 메서드를 생성합니다.

 

jwt에서 token을 claim이라고 부릅니다.

 

Jwts.builder를 활용합니다.

토큰을 set 합니다.

토큰이 생성된 날짜를 set 합니다.

토큰 만료 날짜를 1시간으로 설정하였습니다.

암호화 방식은 HS256을 사용하였습니다.

JWT Compact Serialization 규칙에 따라

URL-safe한 소형 문자열로 직렬화합니다.

 

 

createToken 메서드를 만들었으니

signIn Service 코드의 response body에 token이 나오는지 확인해보겠습니다.

 

그 전에

Controller에서 성공 response body로는

위처럼 출력 할 예정입니다.

 

Common Response body는 생성했으니

그 기반으로

 

단일 결과를 받을 수 있는

SingleResult를 생성하겠습니다.

 

 

SingleResult 생성하기

CommonResult 클래스를 생성했떤

model.response package에 생성합니다.

 

기존에 CommonResult를 상속 받고

추가로 data 라는 key를 추가합니다.

 

이제 ResponseService 클래스 안에

singleResult 메서드를 생성해서

SingleResult 를 response body에서 받을 수 있게 합니다.

 

 

메서드를 생성했으니

controller를 마저 생성합니다.

 

 

완료되었으면 테스트를 합니다.

 

MutableListResult가 필요한 경우

다음 포스팅을 참고하면 좋습니다 :)

 

Postman으로 테스트 하기

 

로그인을 테스트 해봐야 하기 때문에

회원가입을 먼저 합니다.

 

 

 

 

로그인을 테스트합니다.

 

 

 

정상 작동을 확인했습니다!

토큰 생성은 확인했습니다.

 

2. Headers에 입력된 토큰 가져오기

    -> resolveToken

3. 토큰 유효성 체크

    -> validateToken

4. 토큰 복호화 후 subject(pk)가져오기

    ->  userPrimaryKey

5. 사용자에게 권한 부여

    -> userAuthentication

 

JwtTokenProvider Class에서 위 내용을 마저 만들겠습니다.

 

발급받은 토큰을

api headers에 입력하여 권한을 얻기 위해서는

일단 headers에 있는 토큰을 가져와야 합니다.

 

 

resolveToken 메서드 생성

 

token을 가져올 수 있는 Header key 이름은

X-AUTH-TOKEN 으로 진행하고자 합니다.

공식적으로 등록된 이름은 아니지만

단일 api를 호출 할 때 확장성이 좋을 수 있습니다.

 

 

validateToken 메서드 생성

 

token을 parameter로 받습니다.

기존에 Jwts.Builder에서 입력한 secretKey를 넣고

jwtToken을 파싱합니다.

 

jwtToken의 body(payload)유효기간 > 현재시간 인 경우 true를 반환합니다.

 

DefaultJws 클래스를 확인하면 header, body, signature에 대한 정보가 나옵니다.

 

 

userPrimaryKey 메서드 생성

 

토큰을 생성할 때 (createToken 메서드)

claims의 subject에 primaryKey(UUID)를 넣었습니다.

 

반대로 토큰 -> UUID를 가져오기 위해

signature(secretKey)를 넣고 파싱 한 뒤

body안에 subject(primaryKey)를 가져옵니다.

 

 

userAuthentication 메서드 생성

 

토큰이 유효한 경우

해당 토큰으로 API를 허가하는 메서드입니다.

 

메서드를 생성하기 전

이 글에서는

유저 별(토큰 별)로 API 접근 권한을 나누고자 하였습니다.

ROLE_ADMIN 이라는 권한을 가진 유저는

/users/**  API를 호출 할 수 있도록 할 예정입니다.

    -> 토큰 발급 가능한 유저를 CRUD하는 API를 만들 계획입니다.

 

ROLE_USER 라는 권한을 가진 유저는

/users/** 이외에 모든 권한을 줄 예정입니다.

 

ROLE_ 이라는 prefix는 Spring Security에서 지정하고 있는 규칙입니다.

 

유저 별로 권한을 체크하기 위해선

UserDetails 라는 Interface를 사용해야 합니다.

 

Modifier and Type Method Description
getAuthorities() 사용자에게 부여된 권한을 반환합니다.
String getPassword() 사용자를 인증하는 데 사용하는
password를 반환합니다.
String getUsername() 사용자를 인증하는 데 사용되는
Username을 반환합니다.
이 글에서는 email이 됩니다.
boolean isAccountNonExpired() 사용자 계정이 만료되었는지 여부를 나타냅니다.
boolean isAccountNonLocked() 사용자가 잠겼는지 해제되었는지 나타냅니다.
boolean isCredentialsNonExpired() 사용자의 Credentials(password)가
만료되었는지 여부를 나타냅니다.
boolean isEnabled() 사용자의 enable, disable 여부를 나타냅니다.

 

UserMaster Entity 클래스에서

Interface를 상속받은 후 메서드를 override 합니다.

 

위 글에서 중요한 내용은 authorities, username, password 정도입니다.

이외에는 비즈니스에 맞게 설정하면 될 것 같습니다.

 

 

상속받은 내용을 토대로

유저의 정보를 가져올 수 있는 UserDetailsService interface를 상속받아서

메서드를 override 하겠습니다.

 

service 밑에 security package를 생성하고

그 안에 UserDetailsServiceCustom 클래스를 생성합니다.

 

 

위 내용들을 토대로

유저에게 권한을 부여하는 userAuthentication 메서드를 생성하겠습니다.

 

UsernamePasswordAuthenticationToken 라는 메서드를 사용합니다.

추후 SecurityContextHolder.getContext().authentication에 set 될 메서드 입니다.

SecurityContextHolder

SecurityContextHolder는 Spring Security가 누가 인증 되었는지에 대한 세부 정보를 저장하는 곳입니다.

Spring Security는 SecurityContextHolder에 내용이 어떻게 채워지던지 상관하지 않습니다.

값이 채워지면 인증된(authenticated) 유저로 칩니다.

 

Spring Security는 SecurityContext에 설정된 인증 구현 유형에 대해 상관하지 않습니다.

공식 문서에 따르면 일반적인 production 환경에서는 UsernamePasswordAuthenticationToken 사용을 권장하고 있습니다.

인증된 정보를 얻고 싶은 경우

SecurityContextHolder에 접근하면 확인 할 수 있습니다.

 

 

 

위 메서드의 parameters는 아래와 같습니다.

principal : 사용자를 식별합니다. Username/Password를 사용하여 인증 할 때

사용자 세부 정보의 인스턴스가 되는 경우가 많습니다. (UserDetails)

credentials : 암호입니다. 대부분의 경우 사용자가 인증 된 후에 삭제되어 유출 되지 않도록 합니다.

authorities : 인증된 사용자가 가지는 권한입니다. (이 글에서는 USER 혹은 ADMIN 정도가 될 것 같습니다.)

 

 

이제 JwtTokenProvider 클래스 안에 userAuthentication 메서드를 생성합니다.

@Transactional 을 사용한 이유는

토큰을 넣고 api를 호출 했을 때

org.hibernate.LazyInitializationException 오류가 발생하여 추가하였습니다.

LazyInitializationException

user_master 조회를 성공했는데,

user_master의 roles를 조회 할 때

영속성 컨텍스트가 종료되어 버려서

지연 로딩을 할 수 없어서 발생하는 오류입니다.

JPA에서 지연로딩을 하려면

항상 영속성 컨텍스트가 있어야 합니다.

 

때문에 @Transactional을 붙였습니다.

참고자료입니다.

 

JwtTokenProvider 클래스에서 계획한 메서드 생성이 완료되었습니다.

 

이제 filter를 추가하여

요청이 들어올 때 유효한 토큰이면

setAuthentication 하여 호출 허가를 구현하겠습니다.

 

JwtAuthenticationFilter 클래스 생성하기

config.security package 아래

filter package를 생성합니다

그 안에 JwtAuthenticationFilter 클래스를 생성합니다.

 

 

GenericFilterBean을 활용합니다.

bean 속성으로 Filter 취급하는 간단한 필터입니다.

 

필터 적용

토큰의 유효성을 체크하고

유효한 토큰인 경우 요청을 허가하고

아닐 경우 요청을 허가하지 않습니다.

 

이제 위 필터를 SecurityFilterChain에 추가하겠습니다.

 

SecurityFilterChain에 JwtAuthenticationFilter 추가하기

addFilterBefore 메서드를 사용합니다.

알려진 필터 클래스들 중 하나 앞에(before)필터를 추가 할 수 있습니다.

여기서는 JwtAuthenticationFilter 입니다.

filter : beforeFilter 타입 앞에 등록할 filter 입니다.

beforeFilter : 이미 알려진 필터 클래스 입니다.

 

UsernamePasswordAuthenticationFilter 클래스는

인증 처리하는 form을 제출합니다.

Username, Password라는 두 가지의 매개 변수를 제공해야 합니다.

 

매개 변수를 통해 인증을 요청하고

그 정보를 JwtAuthenticationFilter가 수신 받은 뒤 인증 여부를 처리합니다.

 

여기서 의문점이 몇 개 더 생깁니다.

토큰을 가지고 로그인 했을 때

여러 상황을 예측해 볼 수 있습니다.

 

예외처리보완

1. Jwt 토큰 없이 api를 호출하였을 경우

2. 형식에 맞지 않거나 만료된 Jwt 토큰으로 api를 호출한 경우

3. Jwt 토큰으로 api를 호출하였으나 해당 리소스에 대한 권한이 없는 경우(USER 혹은 ADMIN)

 

분량상 다음 챕터에서

테스트 코드와 함께 추가 포스팅을 하겠습니다.

 

대략적인 필터 적용이 끝났습니다.

다음 글에서는

 

필터 적용 후의 테스트 코드를 작성하고

exception handling을 진행하겠습니다.

 

 

 

참고자료

https://jwt.io/

https://spring.io/guides/topicals/spring-security-architecture

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html#servlet-authentication-usernamepasswordauthenticationfilter

https://docs.spring.io/spring-security/reference/servlet/architecture.html

https://www.itworld.co.kr/insight/211794

https://bestinu.tistory.com/60

https://galid1.tistory.com/772

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder

https://blog.outsider.ne.kr/1160

https://stackoverflow.com/questions/49215866/what-is-difference-between-private-and-public-claims-on-jwt

https://medium.com/jongho-developer/jwt-algorithm-hs256-rs256-1ab9f833c486

https://stackoverflow.com/questions/69494662/x-auth-token-vs-x-access-token-vs-authorization-in-jwt

https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/core/userdetails/UserDetails.html

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

https://www.inflearn.com/questions/33949/failed-to-lazily-initialize-a-collection-of-role-%EC%98%A4%EB%A5%98-%EA%B4%80%EB%A0%A8-%EB%AC%B8%EC%9D%98