[스파르타코딩클럽] Spring 심화반 - JWT 로그인 구현하기

hyeseungS·2022년 2월 23일
0
post-thumbnail

01. JWT란?

JWT 사용이유 파악

  1. 서버가 1대인 경우
  • Session1 이 모든 Client 의 로그인 정보 소유
  1. 서버가 2대 이상인 경우
  • 서버의 대용량 트래픽 처리를 위해 서버 2대 이상 운영 필요
- Load Balancer : Client 입장에서는 하나의 API를 호출하는 것 같지만 
                  안쪽으로는 API 처리하는 것을 분산해서 처리해줌
- 서버의 개수만큼 세션의 개수가 늘어남
  • Session마다 다른 Client 로그인 정보를 가지고 있을 수 있음
    • Session1 : Client1, Client2, Client3
    • Session2 : Client4
    • Session3 : Client5, Client6
  • Client1 로그인 정보를 가지고 있지 않은 Server2나 Server3에 API 요청을 하게되면 어떡하지?
: 로그인을 다시 해야하는 문제가 발생
  • 해결방법

1) Sticky Session : Client마다 요청 Server 고정

	ex) Client1은 항상 Server1에 요청
    클라이언트가 항상 로그인되어 있는 것이 아님
    -> 로드 밸런서는 균등하게 처리를 위함인데 하나에 고정되면 그 역할이 사라짐
    -> 매핑 정보가 필요함

2) 세션 저장소 생성

  1. 세션 저장소 생성
  • Session storage 가 모든 Client의 로그인 정보 소유
-> 서버가 세션 저장소를 저장 또는 조회함.
  1. JWT 사용 (또 다른 방법)
  • 세션 저장소를 대체해서 JWT를 사용함.
  • 로그인 정보를 Server 에 저장하기 않고, Client에 로그인 정보를 JWT로 암호화하여 저장 -> JWT 통해 인증/인가(클라이언트들은)
이전) 로그인 정보가 세션/서버에 저장될 때는 클라이언트에 저장하기 전에
     Secret Key를 통해 암호화를 시킴
- 암호화된 JWT를 클라이언트가 가지고 있다가 서버에 요청을 보낼 때 JWT를 같이 보내면, 
  그 내용을 가지고 서버가 이 사용자구나라고 판단
- 이때 복호화하는 과정에서 JWT가 변경/위조가 되었는지  Secret key를 통해 검증
  • 모든 서버에서 동일한 Secret Key 소유
  • Secret Key 통한 암호화 / 위조 검증 (복호화 시)
  • JWT 장/단점
  1. 장점
    • 동시 접속자가 많을 때 서버 측 부하 낮춤
      : 클라이언트에서 정보를 많이 가지고 있어서
    • Client, Server 가 다른 도메인을 사용할 때
      : 도메인이 다르면 CORS 문제 발생 가능
      ex) 카카오 OAuth2 로그인 시 JWT Token 사용
  2. 단점
    • 구현의 복잡도 증가
    • JWT에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 -> 서버)
      : 로그인 정보가 커질 수록 JWT 커짐
    • 기생성된(이미 발급된) JWT를 일부만 만료시킬 방법이 없음
    • Secret Key 유출 시 JWT 조작 가능

JWT 사용 흐름 Overview

  1. Client가 username, password로 로그인 성공 시
    a. "로그인 정보" -> JWT로 서버에서 암호화 (Secret Key 사용)
    Sample

    b. JWT를 Client 응답에 전달
    c. Client에서 JWT 저장 (쿠키, Local storage 등)

  2. Client에서 JWT 통해 인증방법
    a. JWT를 API 요청 시마다 Header 에 포함
    ex) HTTP Headers

    Content-Type: application/json
    Authorization: Bearer <JWT>
    ...

    b. Server

    <1> Client가 전달한 JWT 위조 여부 검증
    (Secret Key 사용(복호화하면서))

    <2> JWT 유효기간이 지나지 않았는 지 검증

    : JWT가 유효기간이 지난 토큰은 로그인이 풀림, 더이상 유효 X

    <3> 검증 성공 시,

    • JWT -> "로그인 정보"
      (UserDetailsImpl) 만들어 사용
      : 원래는 스프링 서버세션에서 로그인 정보를 UserDetailsImpl 만들어서 사용했었음. 
         (서버 세션 대신에 JWT를 사용하고 있음)
      ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회

JWT 구조

  • JWT는 누구나 평문으로 복호화 가능
  • 하지만 Secret Key 가 없면 JWT 수정/발급 불가능
    -> 결국 JWT는 Read only 데이터
  1. Header
  • alg : 암호화 사용 알고리즘
  • typ : 메타정보
    {
      "alg": "HS256",
      "typ": "JWT"
    }
  1. Payload
  • 실제 사용자 정보
    {
      "sub": "1234567890",
      "username": "제이홉",
      "admin": true
    }
  1. Signature
  • 서버에서 변조됐는지 판단하는 사인

     HMACSHA256(
       base64UrlEncode(header) + "." +
       base64UrlEncode(payload),
       secret)

02. JWT 로그인 이해

- 원래는 클라이언트에서 요청 시에 ProductController로 가거나 
  스프링 시큐리티가 중간에서 로그인 과정 처리함
- 로그인 처리는 예외 처리하고 FormLoginFilter로 
  (이는 JWT만들어지기 전이기 때문에)
  DB에서 체크 후 로그인 성공하면 JWT 생성
  JWT 저장
- 관심상품 목록 조회 JWT 포함하여 API 요청.
  예전에는 세션에서 로그인 되었는지 확인하고 ProductController 넘겨진 것을 
  JwtAuthFilter가 그 역할을 함.
  인증 성공하면 조회 가능
  1. JWTAuthFilter : API 요청 Header 에 전달되는 JWT 유효성 인증

    <1> 모든 API 에 대해 JWTAuthFilter 가 JWT 확인
    <2> 로그인 전 허용이 필요한 API 는 예외처리 필요 ⇒ FilterSkipMatcher

    • ex) 로그인 폼 페이지, 로그인 처리, css 파일 등
  2. FormLoginFilter : 회원 폼 로그인 요청 시 username / password 인증
    (FormLoginFilter와 비슷)

    • 인증 성공 시 응답 (Response) 에 JWT 포함 : JWT 전달방법은 개발자가 정함
      ex) 응답 Header 에 아래 형태로 JWT 전달
      Authorization: BEARER <JWT>
      
      ex)
      Authorization: BEARER eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzcGFydGEiLCJVU0VSTkFNRSI6IuultO2DhOydtCIsIlVTRVJfUk9MRSI6IlJPTEVfVVNFUiIsIkVYUCI6MTYxODU1Mzg5OH0.9WTrWxCWx3YvaKZG14khp21fjkU1VjZV4e9VEf05Hok

03. JWT 구현 샘플 프로젝트 확인

1) JWT 구현 샘플 프로젝트

  1. 프로젝트 압축파일 다운로드
    • JWT 샘플 압축파일
    https://www.notion.so/teamsparta/Spring-2-d27c954d8964494993c0d44823b360dc#becb243310da4491a9b222371efbed67
  2. 다운로드 파일 압축 풀기
  3. Intellij에서 File > Open
    • 위 압축 푼 디렉토리 선택하여 프로젝트 Open
개발자도구 -> Preserve Log 클릭
로그인 클릭 시 Authorization 보면 JWT 생성
이는 쿠키에 토큰이라는 이름으로 저장
그 이후 요청맏다 JWT 넣어진다

2) JWT 로그인 적용인증 처리 (Filter)

  • Filter 는 Client 의 API 요청이 Controller 에 전달되기 전, 사전처리를 하는 영역
  • 즉, Controller 에 도달하기 전에 인증 처리를 하기 위해 사용

<1> FormLoginFilter : 회원 폼 로그인 요청 시 username / password 인증

  1. POST "/user/login" API 에 대해서만 동작 필요

    • "GET /user/login" 가 처리되지 않게 하기 위해 API 주소 변경
      : "GET /user/login" -> "GET /user/loginView"
  2. Client 로부터 username, password 를 전달받아 인증 수행
    (attempAuthentication())

    • 요청 처리는 FormLoginAuthProvider에서 authenticate 인증 처리 맞는지 틀린지 처리. (아래 3) 인증처리 확인)
  3. 인증 성공 시

    • FormLoginSuccessHandler 통해 JWT 생성
      (onAuthenticationSuccess() -> UserDetailsImpl 가지고 JWT 생성해서 Response 헤더에 담아줌)
    • 이후 Client 에서는 모든 API 응답 Header 에 JWT 를 포함하여 인증
-> 이제 JWT 생성되었으므로 API 요청마다 JWT를 헤더에 추가해 요청
   이를 JWTAuthFilter가 인증 시도

<2> JWTAuthFilter : API 요청 Header 에 전달되는 JWT 유효성 인증
(attemptAuthentication())

- header에서 JWT 뽑아옴
  null : 로그인 url로 redirect
  null X : JWT가 올바른지 처리해주고 이를 담아 
           스프링 시큐리티에 넘겨줘서 JWT Token을 authenticate 함
		   -> JWTAuthProvider 호출 (JWT 유효성 검사)
			1. username을 복호화하는 동시에 유효한지 체크
               (decodeUsername -> isValidToken -> verify)
            2. 유효기간(expiredDate) 체크
- 반환된 username 가지고 DB에서 User 검색
- Controller에서 이 인증된 사용자를 가지고 UserDetailsImpl 사용함

3) 인증 처리 (Provider)

  • Filter 가 인증에 필요한 정보를 적합한 클래스 형태로 만들어 Spring Security에 인증 요청을 함
  • Spring Security 는 Filter 가 요청한 인증 처리를 할 수 있는 Provider 를 찾고, 실제 인증처리는 Provider 에 의해 진행됨
    • 인증처리 가능 여부 판단기준
      : supports 함수 통해 "인증정보의 클래스 타입"을 보고 판단
      @Override
      public boolean supports(Class<?> authentication) {
          return authentication.equals(UsernamePasswordAuthenticationToken.class);
      }
  1. FormLoginAuthProvider

    • Client 에서 전달한 ID/PW 가 DB 의 ID/PW 와 일치하는지 인증
    @Override
     public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
        // FormLoginFilter 에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
        String username = token.getName();
        String password = (String) token.getCredentials();
    
        // UserDetailsService 를 통해 DB에서 username 으로 사용자 조회
        UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(username);
        if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException(userDetails.getUsername() + "Invalid password");
        }
    
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
    • 인증 성공 시 -> FormLoginSuccessHandler 에서 응답 Header 에 JWT 포함시킴
    public class FormLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
        public static final String AUTH_HEADER = "Authorization";
        public static final String TOKEN_TYPE = "BEARER";
    
        @Override
        public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response,
                                                    final Authentication authentication) {
            final UserDetailsImpl userDetails = ((UserDetailsImpl) authentication.getPrincipal());
            // Token 생성
            final String token = JwtTokenUtils.generateJwtToken(userDetails);
            response.addHeader(AUTH_HEADER, TOKEN_TYPE + " " + token);
        }
    
    }
  2. JWTAuthProvider

    • Client 의 Header 로 전달된 JWT 가 유효한지 검증
    @Override
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        String token = (String) authentication.getPrincipal();
        String username = jwtDecoder.decodeUsername(token);
    
        // TODO: API 사용시마다 매번 User DB 조회 필요
        //  -> 해결을 위해서는 UserDetailsImpl 에 User 객체를 저장하지 않도록 수정
        //  ex) UserDetailsImpl 에 userId, username, role 만 저장
        //    -> JWT 에 userId, username, role 정보를 암호화/복호화하여 사용
        User user = userRepository.findByUsername(username)
                    .orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));;
        UserDetailsImpl userDetails = new UserDetailsImpl(user);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

3) 인증된 사용자 정보 처리

  • Controller에서 사용가능한 인증 사용자 정보 (@AuthenticationPrincipal) 를 만듦
// 신규 상품 등록
@PostMapping("/api/products")
public Product createProduct(@RequestBody ProductRequestDto requestDto,
 @AuthenticationPrincipal UserDetailsImpl userDetails) {
    // 로그인 되어 있는 ID
    Long userId = userDetails.getUser().getId();

    Product product = productService.createProduct(requestDto, userId);
    // 응답 보내기
    return product;
}
  • 현재 문제점) API 호출 시마다 JWT 검증 후 매번 회원 DB (User) 조회 중
    • 원인 : UserDetailsImpl 에서 "User" Entity 객체를 가지고 있기 때문

    • 개선방법 :

      1. UserDetailsImpl 에서 User 객체를 저장하지 않도록 수정

        • 인증 사용자 정보는 Spring Security 가 제공하는 UserDetails 인터페이스 형태만 맞춰서 구현하면됨. 멤버변수는 마음대로 수정 가능

          ex) UserDetailsImpl에 User 객체 대신 userId, username, role만 저장
          (멤버 변수가 꼭 User 객체 가질 필요 없음)

      public class UserDetailsImpl implements UserDetails {
      	// 삭제
       		private User user;
      
      	// 추가
      	private Long userId;
        	private String username;
      	private UserRoleEnum role;
      1. JWT 에 userId, username, role 정보를 암호화/복호화하여 사용

마치며

2주차 끝나고 JWT 따로 정리하고 싶다고 했었는데 어쩌다 보니 지금 정리하게 되었다.. 처음에 들었을 때 어려웠는데 정리하면서 다시 들으니까 대략적인 흐름이 이해갔다. 나중에 이를 실제로 써보면 더 좋을 것 같다. 아즈아😬
출처 : 스파르타코딩클럽


2주차

[스파르타코딩클럽] Spring 심화반 - 2주차


profile
Studying!!

0개의 댓글