JWT - 6.2.4 - 최신버전

고동현·2024년 4월 16일
0

Spring

목록 보기
4/7

JWT는 JMT
이번시간부터 JWT 토큰을 활용하여 로그인, 회원가입등을 처리하는 서비스를 만들어 보겠습니다.

이 글은, Youtube 개발자 유미채널에서 JWT 영상을 보고 작성하는 글 입니다.
해당 링크는 하단에 남겨두도록 하겠습니다.

JWT 인증 방식 시큐리티 동작원리

  • 회원가입

    join으로 요청이 들어오면 컨트롤러에서 서비스로 DTO를 던지고, Service에서 로직을 처리한뒤에 UserEntity를 만들고 UserRepository에서 DB에 저장하는 방식
  • 로그인(인증)

    로그인 경로로 요청이 오면,
    UsernamePassword AuthenticationFilter에서 특정한 회원검증을 작성하고, AuthenticationManager로 id와 password를 던져서 내부적으로 로그인 검증을 한다.
    검증방법으로는 DB에있는 User정보를 꺼내와가지고, UserDetailService가 userDetails에 담아서 최종적으로 AuthenticationManager에서 검증을 한다.
    그래서 로그인이 성공을 하면, JWT방식에서는 SuccessfulAuthentication메서드를 통해서
    JWtUtil에서 토큰을 만들어서 응답을 해준다.
  • 경로 접근(인가)

    토큰이 우리한테 넘어와서 이 토큰을 가지고 특정한 다른, admin경로, 게시판 경로에 접근할때, 항상 이 토큰을 헤더에 넣고 접근을 해야한다.
    그래서 이 방법은, 어떤 특정한 경로로 요청이 오면, SecurityAuthenticationFilter라는 것이, 검증을 먼저 진행하고, JWT FIlter를 만들어서 필터 검증을 진행하도록한다.
    만약에 토큰이 알맞게 존재하고 토큰 내부에 정보가 일치하면, JWT Filter에서 강제로, 일시적인 Session을 SecurityContextHolderSession에서 만들어서 session이 있기 때문에 특정한 admin경로에 들어가던지 할 수 있다.
    다만 이러한 경우, session은 항상 stateless 무상태로 관리하기때문에, 하나의 요청에서 각 하나에 해당하는 Session을 만들어서 사용하기 때문에, 다른 요청이 들어오면, 그 헤더에 있는 토큰을 통해서 동일한 아이디더라도 Session을 새롭게 만들고 그 요청이 끝나면 사라지는 방식으로 동작한다.

추가적으로 해야할것.
Refresh,Accesse Token으로 따로 발행해서 사용하지않고 단일토큰 사용->바꾸기
Spring Security공식문서를 통해서 구조 파악후 내 코드랑 어디가 다른지 또 어디를 수정해야할지 판단하고 리팩토링 해보기.

프로젝트 만들기

  • Lombok
  • Spring Web
  • Spring Security
  • Spring Data JPA
  • MySQL Driver

데이터베이스 의존성 주석처리
스프링 부트에서 데이터베이스 의존성을 추가한 뒤 연결을 진행하지 않을경우 런타임 에러가
발생하므로, 임시로 주석처리 후 진행

jpa와 mysql 드라이버를 주석처리

JWT 필수 의존성
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
복사하기

리로딩

기본 Controller 생성
admin과 main controller 두가지를 생성하였습니다.


둘다 초기 단계라서 그냥 스프링 띄우고 제대로 되나 확인하기 위해서 문자열 return을 해주었습니다.
이거 ResponseBody안적으면 view 파일 찾으러 가기때문에 ResponseBody어노테이션을 적어주세요

SecurityConfig class
스프링 시큐리티와 인가 및 설정을 담당하는 클래스 입니다.
먼저 기본적인 설정만 진행하고 시리즈를 진행하며 커스텀 필터 요소들을 추가 구현할 예정입니다.



하나하나 설명을 해보자면,
우선 우리는 SecurityFilterChain이나 BCryptPassword 클래스를 컨테이너에 등록해서 사용할 것이기 때문에, 이러한 외부라이브러리를 컨테이너에 올려놓으려면,
Configuration을 통해서 Bean으로 등록시켜야합니다.

BCryptPasswordEncoder클래스의 메서드는 비밀번호를 암호화하기위해서 사용됩니다.

session방식에서는 session이 고정되기 때문에 csrf공격을 방어해야합니다. 그러나 jwt에서는 우리는 session을 stateless상태로 설정하기때문에 csrf를 disable시킵니다.

csrf 공격이란?

그리고 Form login방식을 비활성화합니다.
사용자가 로그인 폼에 자신의 아이디와 비밀번호를 입력하면, 서버에서 이 정보로 사용자를 인증합니다. 이 과정에서 스프링 시큐리티는 사용자 세션을 생성하고 관리하여 로그인 상태를 유지하게 되는데, 우리는 스프링 시큐리티가 기본적으로 제공하는 로그인 폼을 사용하지 않고 JWT와 같은 토큰 기반 인증을 사용하기 위함입니다.

그래서 서버에서는 클라이언트 상태(로그인 상태인지 아닌지)를 유지 하지 않습니다.
대신, 클라이언트는 매 요청마다 서버에게 자신이 누구인지 증명하는 JWT토큰을 전달합니다.
따라서 JWT를 사용할때는 세션을 생성하고 관리할 필요가 없으므로, Form Login방식도 필요하지 않습니다.

즉, 폼 로그인을 비활성화시키면, 스프링 시큐리티는 자동으로 생성되는 로그인 페이지를 제공하지 않게 됩니다.

다음은 경로별 인가작업을 분리해야합니다.
/login,/,/join과 같은경우에는 당연히 로그인을 할 수 없는 상태에서 접근해야하므로, 접근허용을 해주고
그다음 /admin으로 접근시에는 role이 admin인지 확인
그리고 나머지 anyRequest에는 로그인을 해야지만 접근이 가능하게 하였습니다.

제일중요
마지막으로 세션설정을 해줘야하는데 우리는 JWT를 사용할 것이기 때문에 항상 session을 stateless로 관리해야합니다. 왜냐? 사용자의 로그인 상태를 저장하지 않고, 항상 요청을 보낼때 마다. 토큰값으로 접근가능 여부를 판단할 것이기 때문입니다.

DB설정
우선 맨앞에서 gradle에서 주석처리했던부분 2개를 풀어주고 다시 빌드해준다.

그다음에 application 프로퍼티스에서 설정

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://아이피:3306/데이터베이스?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
spring.datasource.username=아이디
spring.datasource.password=비밀번호

spring.jpa.hibernate.ddl-auto=none
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

여기서 다른건 다 알겠는데 ddl-auto=none으로 설정한 이유가.
create 옵션을 사용하면 애플리케이션이 시작될 때마다, 기존의 테이블을 삭제하고, 새로운 테이블을 생성하기 때문에, 기존 데이터가 모두 사라집니다.

그래서 초기 스키마 설정이나 테스트 목적으로 create를 사용한 후, 데이터 유실을 방지하기위해 개발이 어느정도 진행 된후에는 update나 none으로 변경하는 것이 좋다.

UserEntity

UserRepository

Entity기반으로
이제 데이터베이스에 Entity를 기반으로 Table을 만들어야하는데, 그 테이블을 스프링기반 Entity를 기준으로 만들수 있습니다.

create로 변경후 실행
그리고 반드시 none으로 변경

참고.)
그냥 이렇게 실행을 해버리면, 우리가 어플리케이션 프러파티에서 스키마 설정을해줬는데
pring.datasource.url=jdbc:mysql://아이피:3306/데이터베이스?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
여기 데이터베이스부분, 이거 스키마를 만들고 실행을해야
Error creating bean with name 'entityManagerFactory' defined in class path resource
이 오류를 안만난다.


제대로 테이블이 생성되었다.

회원가입 로직 구현
우선 JoinDto를 만들어 주자.
Join DTO

JoinController

Postmapping으로 요청이 /join으로 들어오면,
joinService의 joinprocess메서드에 joinDto를 인자로 호출한다.
JoinServicce

공식문서에 Autowired보다 생성자 주입을 권고하므로 생성자 주입방식으로 구현하였다.

Joinprocess에서는 JoinDTO에서 username과 password를 가져와서 해당 username이 User가 있는지 확인 해야한다.
그래서 bool형으로 확인한 후에,
if문에 있으면 return => 로직채워줘야함

그다음에 없으면, UserEntity를 만들어서 set으로 설정한후에
userRepository.save로 저장해준다.

여기서 일단은 역할을 전부 관리자로 만들고, 그다음에 비밀번호를 설정할때는 반드시
bCryptPasswordEncoder를 통해서 암호화한 후에 저장한다.

UserRepository
existsByUsername을 통해서 username에 해당하는 UserEntity를 찾는다.

로그인 필터 구현
여기서 부터 조금 까다로워지는데 Spring Security의 공식문서와 최대한 비슷하게 맞춰서 구현을 해보겠다.(영어가 어려운...ㅠ)
Spring Security 아키텍쳐 문서

일단은 /login으로 요청이 들어오면, UsernamePasswordAuthenticationFilter가 요청에서 들어온 username과 password를 꺼내서 AuthenticationManager한테 넘겨준다. 그러면 AuthenticationManager가 DB로 부터 회원정보를 가지고 와서 검증을 진행하고 확인이 되면 successfulAuthentication이 작동을합니다. 만약 검증이 안되면 unsuccessfulAuthentication이 동작한다.->우리는 401을 return할꺼임

일단 Session방식에서는 이걸 구현을 안해도되는게, 왜냐면 알아서 Spring이 기본 Default로 처리를 해주기 때문이다.

그런데 우리는, Form login인 방식을 disable시켰다 앞에서,

왜냐하면 스프링 시큐리티는 Form Login 인증방식을 제공하는데
대략적으로 그림을보면,

이런식으로 username과 password를 통해서 검증을 마치면, session으로 유저정보를 저장해둔다.
그런데, 우리는 Session을 사용하지않고 JWT를 사용하기 때문에, 이 기능을 disable시켜놓은것이다.

스프링 시큐리티 필터 동작 원리

spring 시큐리티에서는 client의 요청이 여러개의 필터를 거쳐서 컨트롤러로 향하게 된다.

우리의 스프링 부트 어플리케이션은 servletContainer(톰켓)위에서 동작을 하게 되는데, 그래서 client한테 요청이오면 톰캣의 서블릿 필터들을 통과해서 이 springboot의 controller로 전달이 되는데,
이 필터를 활용해서 springSecurity를 활용하게 됩니다.

여기서 이 많은 필터중에 우리는 하나를 등록을 해야합니다.
우리는 필터체인에서 DelegatingFilter를 등록한뒤에 모든 요청을 가로채게됩니다.

이렇게 가로챈 요청들은 securityFilterChain으로 또 가로채게 됩니다.

여기 그림에서 FilterChainProxy는 어떤 SecurityFilterChain을 사용할지 결정한다.
뭐 요청이 /api로 시작하는지 아니면 루트/에서 시작하는지 결정해서 일치하는 첫번째 항목만 SecurityFilterChain 호출된다.

In the Multiple SecurityFilterChain figure, FilterChainProxy decides which SecurityFilterChain should be used. Only the first SecurityFilterChain that matches is invoked. If a URL of /api/messages/ is requested, it first matches on the SecurityFilterChain0 pattern of /api/, so only SecurityFilterChain0 is invoked, even though it also matches on SecurityFilterChainn. If a URL of /messages/ is requested, it does not match on the SecurityFilterChain0 pattern of /api/, so FilterChainProxy continues trying each SecurityFilterChain. Assuming that no other SecurityFilterChain instances match, SecurityFilterChainn is invoked.

그러면 해당 체인을 따라서 SecurityFilterChain이 FilterChainProxy에 삽입이 된다.
그러면 이 필터가 적시에 호출되도록 특정 순서로 실행이된다.

The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more.


기본적으로 이런순서로 필터 목록과 순서가 진행되는데,
Form 로그인 방식에서는 클라이언트가 username,password를 보내면 SecurityFilter 체인을 통과하는데 여기서 UsernamePasswordAuthenticationFilter에서 회원검증을 진행한다.

회원검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 이 매니저가 DB에서 조회한 데이터를 UserDatilsService를 통해서 받는다.

그런데 우리는 formLogin방식을 disable하였기 때문에 이 UsernamePasswordAuthenticationFilter가 동작하지 않으므로, 로그인을 진행하기 위해서 이 필터를 커스텀해서 등록해야한다.

후.. 여기까지 배경설명이 끝났고 실제로 필터를 만들고 등록해보자

UsernamePasswordAuthentication 필터 작성
로그인 검증을 위한 커스텀 필터 작성

일단 커스텀 필터이기 때문에 LoginFilter를 만들서 UsernamePasswordAuthenticationFilter를 상속받을 것이고,
그다음에 커스텀해야하니까 Override로 attempAuthentication 메서드를 작성해준다.
여기서 obtainUsername과 obtainPassword를 통해서 username과 password를 가져오는데
여기서, 아까 위에 모식도에서 이거 검증해줘 하고, AuthenticationManager한테 던지는데
그냥 던지면 안되고, DTO처럼 바구니에 담아야하는데 그게 바로 UsernamePasswordAuthenticationToken이다.

그래서 return값으로 authenticationManger에서 authenticate메서드를 호출할때 인자로 이 authToken을 넘겨줘서 실행한다.

참고로 AuthenticationManager도 DI받아서 사용해야한다.

그러면 AuthenticationManager가 검증을 담당하는데 DB에서 회원정보를 땡겨와가지고 userDetailsService를 통해서 user정보를 받고 검증을 진행한다.

그래서 이 검증이 성공하면,
successfulAuthentication 메서드를 실행시키고, 실패하면 unsuccessfulAuthentication 메서드를 실행시킨다.

  @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {

    }

이것도 동일하게 로그인 필터 안에 있다.

여기까지 대략적으로, 아 일단 클라이언트로 받아온 username과 password를 가지고 -> UsernamePasswordAuthenticationToken을 만들어서, -> AuthenticationManger한테주고 얘가 이 token을 가지고 DB에서 실제 유저가 있는지 판단하는구나 까지 이해하면된다.

어쨋든 결국 우리가 새로만든 필터도 등록을 해야 당연히 사용할 수 있을것 아닌가?
등록해주자.

SecurityConfig에 추가

그런데 보면 LoginFilter를 등록시키는데 우리가 LoginFilter class가보면, authentication을 주입받았다. 고로
여기서 파라미터로 넘겨주려면 여기 securityConfig에서도 DI받아야한다.

그런데 여기 AuthenticationMananger에서도 파라미터로 AuthenticationConfiguration 을 받아야한다.
그래서 결국, SecurityConfig에서 AuthenticationConfiguration을 DI해준다.

지금까지

지금까지 Authentication Manager에 userid,password에 해당하는 토큰을 가지고 있는 상태이고, DB에서 UserEntity를 가져와서 검증한는건 아직 안한 상태이다.
UserDetailsService와 UserDetails를 구현해야한다.
UserDetailsService가 DB로부터 특정한 정보를 가져와서 UserDetails에 넘겨가지고, 최종적으로 Authentication Manager에서 검증을 진행하게 된다.

UserRepostiroy

CustomUserDetailsService
우리가 UserDetailsService를 커스텀해서 만들어야하기 때문에, CustomUserDetailsService를 만들고 UserDetailsService를 implements한다.

UserDetailsService를 imple하게 되면 반드시 loadUserByUSername을 오버라이딩 해야만 합니다.
그래서 userRepository에서 위에서 정의한 ORM으로 UserEntity를 찾고,
그다음에 null이 아니라면, 위에 모식도와같이 UserDetails를 만들어서 넘겨줘야한다.
당연히 여기서는 CustomUserDetails class는 만들지 않았기 때문에 만들어야한다.

만약 DB에서 가져오려했는데 해당 UserEntity가 없다면 null을 반환한다.

CustomUserDetails
여기서는 DTO와 비슷하게 UserEntity에대한 정보가 들어있다. 그래서 get방식으로 password,username,만료되었는지아닌지 ,등등을 확인 할 수 있다.
CustomUserDetails는 UserDetails를 imple한다.
그러므로 getAuthorities, get Password,,,등등 메서드를 오버라이딩 해줘야하는데, 해주고
내부 로직을 구현하면 된다.
여기서 위에서 CustomUserDetailsService에서 new할때 인자로 userData를 넘겨줬으므로,
생성자에 파라미터로 userEntity를 넣어서 만들어 주면된다.


메서드 선언
getAuthorities() 메서드는 Collection<? extends GrantedAuthority> 타입을 반환한다. 이는 Spring Security에서 사용자의 권한 목록을 의미한다. GrantedAuthority는 사용자의 권한을 나타내는 인터페이스이다.
권한 컬렉션 생성
ArrayList의 인스턴스를 생성하여 collection 변수에 할당합니다. 이 컬렉션은 나중에 반환될 사용자의 권한들을 담게 됩니다.
익명 클래스를 이용한 GrantedAuthority 구현
collection에 GrantedAuthority의 익명 구현체를 추가합니다. 이 구현체는 getAuthority() 메서드를 오버라이드하여, 실제 사용자의 권한을 나타내는 문자열을 반환합니다.
여기서 userEntity.getRole()은 사용자 엔티티에서 사용자의 역할(권한)을 가져옵니다. 이 역할은 GrantedAuthority 객체에 의해 권한으로 사용됩니다.
권한 컬렉션 반환
최종적으로, collection을 반환합니다. 이 컬렉션에는 사용자의 모든 권한이 GrantedAuthority 객체의 형태로 담겨 있습니다.

boolen형 메서드들의 반환형을 전부 true로 해준다.
여기를 왜 검증을 안하고 true로 반환했는지 모르겠는데, 댓글을 남겼는데

아직 그냥 안만든거라고 한다. 디폴트로 true를 해야 로그인이 가능한거고 내가 추가 만료 로직을 구현해야한다고 한다.

여기서부터 진짜 집중
여기까지 왔으면 대충 이게 말이되나? 싶으면 아주 정확하게 여기까지 글을 이해한것이다.

대충 아.. 그니까 AuthenticationManger에서 로그인 할때 username과 passowrd가지고 UsernamePasswordAuthenticationToken을 발급받고, 이 토큰값으로 CustomUserDetailSerivce에서 loadUserByUsername메서드로 검증을 하는구나.
라고 넘어가면 안된다.

  1. UsernamePasswordAuthenticationToken이 도대체 어디서 쓰는건지
  2. 도대체 AuthenticationManger가 UserDetailService를 어떻게 부른다는건지
  3. loadUserByUsername메서드는 어디에서 이 함수를 호출해서 검증을 하는건지

그 무엇도 이 글을 읽고있는사람은 알지 못한다.
그냥 아 대충 토큰가지고 인증하면 되겠지 하고 넘긴다면, 그냥 기능만 대충 쓸 줄 아는 개발자가 될것이다.

다시, 깊게 설명을 해보자면
일단 맨처음에 요청이 들어오면, UsernamePasswordAuthenticationFilter로 넘어간다고 배웠다.

이 필터는 AbstractAuthenticationProcessingFilter를 상속받고 있다.
여기에서는 doFilter()와 attemptAuthentication()이라는 추상 메서드가 존재하는데, 즉 UsernamePasswordAuthenticationFilter에서 doFilter메서드를 실행하면, 이 필터에 맞는 로직이 실행되는 것이다.

그래서 doFilter를 보면

중간에 빨간줄로, attemptAuthentication(request,response);가 있다.

이러면 실제 구현체인 UseranmePasswordAuthenticationFilter에서 attemptAuthentication이 실행이되고,

여기서 봐야할것은 UsernamePasswordAuthenticationToken과 반환 부분에서 return this.getAuthenticationManager().authenticate(authRequest); 이다.

어? 우리 앞에서 UsernamePasswordAuthenticationToken에서 그때 username,password,null 이거 넘겨줬던거 기억이 나는가?


UsernamePasswordAuthenticationToken에서 생성자가 두가지 이다. 결국 우리가 null로 넘겨줄때 생성된건 false라서, 아직 인증되지 않은 Token을 만든것이고, 나중에 만약 인증이 된 token이라면 true로 생성자를 만들것이다.

여기까지 우리는 UsernamePasswordAuthenticationFilter->AbstractAuthenticatioNProcessingFilter->여기서 doFilter실행 -> doFilter내부에 attemptAuthentication메서드 실행 -> 구현체인 usernamePasswordAuthenticationFilter attemptAuthentication실행 -> username,과 password로 검증되지 않은 UsernamePasswordAuthenticationToken 만듬

여기까지 한것이다.
그러면 또 궁금해야하는것이 이거다. 토큰이 뭐냐? 이 원초적인 물음이다. 도대체 토큰이 정확히 뭐길래 토큰 토큰 하는거냐?

다시 usernamePasswordAuthenticatioNToken으로 가보자.

UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받는다.


다시 AbstractAuthenticationToken을 보면 Authentication을 상속받고 있다.
즉 UsernamePasswordAuthenticationToken은 나중에 인증이 되면, SecurityContextHolder.getContext()를 통해 등록될 Authentication객체인것이다.

그다음에 다시 위로 돌아가서 attempAuthentication에서 return 부분을 보면 this.getAuthenticationManager().authenticate(authRequest)부분을 보자.
그러면, 위에서 만들었던, 인증이 되지않은 UsernamePasswordAuthenticationToken을 인자로 넣고 있다.

그러면 UsernamePasswordAuthenticationFilter가 상속한 AbstractAuthenticationProcessingFilter에 있는 AuthenticationManager객체를 가져와서 authenticate메서드를 실행하는것이다.

그러면 AuthenticationManager를 봐보자.

AuthenticationManager는 인터페이스로 되어있고, authenticate메서드만 정의 되어있다.

AuthentiationManager는 AuthenticationProvider라는 클래스 객체를 관리한다.
AuthenticationProvider에 실제 인증 로직이 담겨 있는 객체라고 보면된다.

authenticate메서드를 보면 Authentication객체를 반환하는데, 이게 authenticate메서드를 실행해서 authenticationProvider 객체를 통해서 인증이 완료가 되면, 인증된 Authentication객체를 반환하는것이다.

그렇다면 AuthenticationManager가 인터페이스니까 UsernamePasswordAuthenticationFilter에서 사용하는 AuthenticationManager 구현체는 Providermanager클래스이다. 기본적으로 spring security는 이 기본 Providermanager를 사용한다.

실제 Providermanager 클래스에 가보면 AuthenticationManager를 상속하고 있는걸 볼 수 있다.
그러면, 다시 위로 올라가서 this.getAuthenticatonManager()를 하면 AuthenticationManager를 가져오는데 이건 인터페이스고 스프링 시큐리티는 기본적으로 ProviderManager를 사용하므로 결국 providerManager.authenticate(authRequest) 임을 알 수 있다.

Providermanager의 authenticate메서드로 가보면

for문에서 AuthenticationProvider로 돌리는것을 볼 수 있다. 아까 앞에서
AuthenticationManager은 AuthenticationProvider를 관리한다고 하였다.

AuthenticationProvider는 authenticate와 supports가 있는 인터페이스 이므로

위의 authenticate메서드 내부에서 result = provider.authenticate(authentication)을 통해 실제 authenticate 로직이 수행되고

그 위 코드 if(!provider.supports())를 통해 매개변수로 받은 Authentication객체의 구현 클래스가 AuthenticationProvider객체에서 사용하는 Authentication객체가 같은지 확인한다.

왜냐하면 우리 authenticate메서드에 넘어온 authentication을 가지고 우리는 인증을 진행하는데 이 authentication객체는 AuthenticationProvider마다 각각 다르기 때문에 support()를 통해 Authentication객체에 맞는 AuthenticationProvider를 찾는다.

다시 돌아와서 우리가 사용할 파라미터로 넘어온 Authentication객체가 무엇이냐? 바로 UsernamePasswordAuthenticationToken이다.
그러면 AuthenticationProvider는 인터페이스니까 실제로 호출된 구현체는 AbstractDetailsAuthenticationProvider클래스이다.

실제로 AuthenticationProvider를 상속받은걸 볼 수 있다.

이제 드디어 authenticate()메서드를 봐보자,

여기를 보면 user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); 에서 username user의 id와 Authentication객체를 가지고 UserDetails객체를 가져온다.

retrieveUser메서드는 추상메서드로 되어있는데

UserDetails를 가져온다.

그러니까 이 추상메서드를 실제로 구현한 자식클래스가 있다는 말인데,
그게 바로 DaoAuthenticationProvider클래스이다.

실제로 AbstractUserDetailsAuthenticationProvider를 상속받은 것을 알 수 있다.

그러면 이제 여기 DaoAuthenticationProvider에서 retrieveUser를 봐보자.

어? 이러면 어기서 알수있다.
아 도대체 AuthenticationManager가 UserDetailsService를 불러서 UserDetails를 반환하나 싶었는데? DaoAuthenticationProvider에서 CustomUserDetailsService를 불러서 여기서 오버라이드된 loadUserByUsername메서드를 호출해서 UserDetails를 가져오는거였다.

그러면 궁금증 2번까지 한거니까 다시 정리를 해보자면,
UsernamePasswordAuthenticationFilter->AbstractAuthenticatioNProcessingFilter->여기서 doFilter실행 -> doFilter내부에 attemptAuthentication메서드 실행 -> 구현체인 usernamePasswordAuthenticationFilter attemptAuthentication실행 -> username,과 password로 검증되지 않은 UsernamePasswordAuthenticationToken 만듬->this.getAuthenticatonManager().authenticate(authRequest->검증안된 UsernamePasswordAuthenticationToken) -> AbstractDetailsAuthenticationProvider의 authenticate메서드에서 retrieveUser메서드 호출-> DaoAuthenticationProvider에서 retrieveUser구현 메서드에서 CustomUserDetailsService를 불러서 오버라이드된 loadUserByUsername호출 해서 UserDetails반환 까지 한거다.

당연히 UserDetails는 userRepository에서 그냥 이름가지고 찾아온거다.

이제 authentication과 UserDetails가 동일한지 검사해야한다.

다시 AbstractUserDetailsAuthenticationProvider로 돌아와서
try부분을 보면 여기 additionalAuthenticationchecks메서드의 인자에 UserDetails과 authentication을 UsernamePasswordAuthenticationToken으로 캐스팅해서 넘겨주는걸 볼수 있다.

그러면 다시 dao로 넘어가서

드디어 autentication과 UserDetails의 username이 동일한지 확인하고 있다.

그럼 비밀번호는? 검사를 안하나? 싶었는데 찾아보니까

AuthenticationManager의 authenticate() 메서드 내부 구현을 따라가보면, 미리 빈으로 등록해 둔 PasswordEncoder로 UserDetails의 password와 matching 작업이 구현되어 있어서 올바르게 비밀번호 검증이 된다. 라고 한다.

어쨋든 이렇게 검증을 끝내고, 동일하다면 이제 마지막으로 AbstractDetailsAuthenticationProvider의 authenticate()마지막 부분에 createSuccessAuthentication(principalToReturn, authentication, user)가 반환된다.

다시 dao로 가서 확인해보면

createSuccessAuthentication메서드를 호출하고 있고,
super이므로 AbstractDetailsAuthenticationProvider에가서 확인해보면

마침내 생성자 3개짜리 UsernamePasswordAuthenticationToken 생성자를 호출하고 있다.
이전에도 Authentication객체를 만들긴 했는데 이건 인증이 안된거고, 결국
타고타고 가다가, 마지막에 3개짜리 authorities를 설정한, 인증된 Authentication객체를 만든것이다.

이걸 그럼 어디다 반환하냐? 우리가 맨처음으로 다시올라가다보면, AbstractAuthenticationProcessingFilter의 doFilter에서 실행된 것이었다.

결국 attempAuthentication메서드를 통해서 우리는 검증된 Authentication 즉, UsernamePasswordAuthenticationToken을 가지게 되었고 마지막으로 successfulAuthentication메서드의 인자로 호출해준다.


마침내 successfulAuthentication메서드에서 SeurityContext context에서 context.setAuthentication으로 이 authResult인 아까 위에서 아규먼트로 넣어준 UsernamePasswordAuthenticationToken을 넣어주면서, 인증작업이 끝난다.

휴... 길지만 여기까지가 로그인시에 username과 password를 검증한는 인증작업이다.
이후로 우린 이 검증후에 JWT발급과 더불어 인가 작업도 알아보겠다.

Jwt 발급

지금 보면 UserDetailsService에서 DB에서 해당 UserEntity가져와서 UserDetails 바구니에 넣어서 AuthenticationManager에서 검증을 한 상태이다.
만약에, 정상적이라서 JWT를 발급해야하는 경우를 살펴보겠다.

JWT발급과 검증
로그인시 -> 성공 -> JWT 발급
접근시 -> JWT 검증

JWT 생성원리
JWT 공식문서

JWT Encoded부분을 보면 .을 기준으로 3가지 파트로 나눌 수 있는데
첫번째, header 두번째 payload 세번째 signiture

  • Header
    JwT임을 명시
    최종적으로 JWT를 암호화해서 JWT를 발급한 발급처에서만 검증이 가능하게 할것인데, 그때 사용한 암오화 알고리즘을 나타낸다.
  • Payload
    정보, username, Role, 발급일자 등등
  • Signuture
    이 토큰을 발행한 서버에서만 확인하고, 아니면 시크릿키를 가지고 있는 서버끼리 검증하도록, Base64방식으로 암호화해서 사용하려고한다.

★★★★★★★ JWT의 특징은 내부정보를 단순히 BASE64 방식으로 인코딩 하기 때문에 외부에서 쉽게 디코딩이 가능하다. 그러므로, 외부에서 열람해도 되는 정보를 담아야한다.
그러면 어차피 비밀번호도 못넣는데 이걸 왜쓰냐?
바로, 토큰 자체의 발급처를 확인하기 위함이다.
이게 내가 정상적으로 발급한 토큰이라서 이 토큰을 검증해도 되는가? 이걸 확인하는거다.
=> 지폐와 같이 외부에서는 그 금액과 외형을 따라 위조 할 수 있지만, 원본 위조는 힘들다, 엄청 그 안에 홀로그램이라던지 위조 방지 기능이 있으니까
고로, 금액이 중요한게 아니고 지폐 자체, 즉 토큰자체가 내가 발급한지 확인하는것이다.

고로, 토큰 내부에 비밀번호같은 값을 입력하면 안된다.

그래서 만약에 해커가 JWT를 payload부분에 ROlE를 일반인이아니라, admin으로 설정하더라도, signiture부분에서 토큰이 내가 발급한게 아니라는것을 알 수 있기때문에 반려된다.

Jwt 암호화 방식
암호화 종류

  • 양방향
    대칭키:HS256
    비대칭키
  • 단방향

우리는 양방향 대칭키를 사용할 것임

암호화 키 저장

JWT Util
이제 JWT토큰을 만들 JWTUtil class를 만든다.

  • 토큰 payload에 저장될 정보
    username,role,생성일,만료일
  • JWTUtil 구현 메서드
    생성자,username확인 메서드, role 확인 메서드, 만료일 확인 메서드



여기서 JWTUtil을 생성할때 아까 설정했던 시크릿키를 가져와서 특정하게 JWT에서 객체TYPE으로 새롭게 시크릿키를 저장하면서 암호화해야한다.
그래서 private SecretKey를 만들고 여기다가 객체 변수로 암호화해서 넣어준다.=>아까 이거 알고리즘 위에서 HS256이라 했음

그리고 get메서드에서 아까 만든 secretkey로 우리 서버에서 만든것이 맞는지 검사한 후에 get방식으로 해당 username,role등을 가져온다.

Expire메서드도 비슷하게 구현하면된다.

그다음에 마지막으로 creatJWT메서드를 통해 username과 role을 claim메서드를 통해서 넣어주고, 생성하면 된다.
키설정은 아까 만들었던 secretKey객체를 넣어주면된다.

로그인 성공 JWT 발급
아까 만든 JWT 토큰을 결국 LoginFilter에서 사용해야하니까 DI를 해준다.

그런데 문제는 LoginFilter를 사용하는 SecurityConfig에서
인자로 jwtUtil을 넘겨주지 않았기 때문에,

securityConfig에서

jwtUtil을 생성자 주입받고, 그다음에,
추가해준다.

successfulAuthentication메서드 구현
jwt를 만들어서 response에다가 넣어줘야한다.

우선, CustomUserDetails에서 username을 가져오고 그다음
컬렉션으로 authorities를 가져온다음에,
jwtUtil.createJWT메서드를 통해서 username,role,생명주기를 넣어서 호출한다.
그다음에 response의 헤더에다가 이 토큰을 넣어주는데, Key값은 Authorization이고,JWT data는 인증방식인 bearer를 접두사로 붙이고 띄워쓰기를 하나 한다음에 token을 붙인다.

왜 Bearer를 붙어야하냐면
HTTP 인증 방식은 RFC 7235정의에 따라,
Authorization: 타입 인증토큰이므로
우리는 Bearer타입이므로 Bearer를 붙여야한다.

로그인 실패구현

실패시 간단하게 401오류를 보냈다.

결과

실제로 해보면 헤더에 Authorization키에 JWT토큰이 들어있는것을 확인 할 수 있다.

JWT 검증필터
현재 상태에서 postman에서 http://localhost:8080/admin으로 접근하려고하면 안된다.
왜냐하면, 발급받는 토큰을 검증하는 로직은 안만들었기 때문이다.

JwTfilter구현

public class JWTFilter extends OncePerRequestFilter {
    private final  JWTUtil jwtUtil;

    public JWTFilter(JWTUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization= request.getHeader("Authorization");

        //Authorization 헤더 검증
        if (authorization == null || !authorization.startsWith("Bearer ")) {

            System.out.println("token null");
            //여기서 그냥 끝내는게아니고, dofilter로 filterchain을 다 끝내고 return하는것 같음
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }
}

여기서 JWTFIlter를 OnceREquestFilter를 상속받아서 사용할것인데,
doFilterInternal을 구현해야한다.
Jwt를 Request에서 뽑아내서 검증할건데, 이러면 JWTUtils에서 마련했던 그 검증 메서드를 사용해야한다.
그러므로, JwtFilter에 JWTUtil을 DI받는다.
여기서 key값인 Authorization에서 토큰을 받아오고
이게 접두사 Bearer이 맞는지 확인, 아니더라도 그냥 끝내는게 아니고 필터체인의 다음필터를 끝내고 넘어가야한다.
조건이 해당되면 return으로 반드시 메서드 종료를 해야한다.

  //Bearer 부분 제거 후 순수 토큰만 획득
        String token = authorization.split(" ")[1];

        //토큰 소멸 시간 검증
        if (jwtUtil.isExpired(token)) {

            System.out.println("token expired");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

여기서 토큰만 불리해서 소멸시간 검증을한다.

현재 여기까지 하면, 토큰도있고 소멸시간도 안지난 상태이다.
그러므로, 그 토큰을 기반으로 일시적인 session을 만들어가지고 그 세션에 User정보를 넣어서, securityContextHolder라는 securitysession에다가 넣어야한다.

이러면 특정한 경로 admin같은거 들어갈때, user정보를 요구하는 요청에 여기 있던 securitySession에서 가져와서 확인한다.

 //현재 토큰도 있고 소멸시간도 안지남
        //그 토큰을 기반으로 일시적인 session을 만들어가지고 securityContextHolder라는 securitysession에다가 넣어야한다.

        //토큰에서 username과 role 획득
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        //userEntity를 생성하여 값 set
        UserEntity userEntity = new UserEntity();
        userEntity.setUsername(username);
        //비밀번호는 DB에 임시로 만들고
        userEntity.setPassword("temppassword");
        userEntity.setRole(role);

        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);

UseEntity를 만들어서 우리는 여기다가 set으로 설정을 해주는데, 앞에서 말했듯이 password에는 비밀번호가 들어가면 안되기 때문에 setPassword에다가 임시로 그냥 아무 문자열을 넣어준다.

그렇게 만들어진 USerEntity를 CustomUserDetails에다 넣어서 CustomUserDetails객체를 만들어주고,
이 만든 userDetail객체를 가지고 UsernamePasswordAuthentication토큰을 만들어가지고, Auth 토큰을 생성한다. 이 authToken를 최종적으로 SecurityContextHolder에 넣어준다.
이러면, UserSession을 만들 수 있다.
그리고 우리는 이 UserSession을 통해서 특정한 경로에 접근이 가능하다.

SecurityConfig
우리는 이 JWT를 검증하는 필터를 만들었으니까 당연히 이 필터를 등록해야 사용이 가능하다.

저번에 등록한 로그인 필터 앞에다가 등록하였다.
왜 로그인 필터 앞에다가 등록해야할까?
필터 순서의 중요성
JWTFilter가 먼저 동작: 모든 HTTP 요청에 대해, JWTFilter가 먼저 실행되어 요청에 포함된 JWT 토큰의 유효성을 검증한다. 이는 사용자가 이미 로그인했는지를 확인하는 과정이며, 유효한 토큰이 있다면 해당 요청은 인증된 것으로 간주되어 다음 필터나 리소스에 접근할 수 있다.

LoginFilter가 그 다음에 동작: 로그인 요청에 대해서만 LoginFilter가 작동하여 사용자의 로그인 정보를 검증하고, 성공적으로 로그인한 경우 새로운 JWT 토큰을 생성하여 반환한다.

그래서 만약에 LoginFilter가 앞서게 되면, LoginFilter에서 만약 JWT검증이 필요한 리소스에 접근하게 되면, 문제가 발생할 수 있기 때문이다.

Test

토큰값을 넣어서 admin에 접근하면 200이 뜬다.

Sesseion정보
물론 JWT 자체가 session을 stateless하게 관리하긴 하지만, JWT를 가지고 JWTFIlter를 통과하게 되면, 일시적으로 세션을 만들기 때문에,
SecurityContextholder에서 username과 ROle값을 확인 할 수 있다.

main컨트롤러에다가 구현해보면, 컬랙션으로 role값을 가져오고, 그다음에 username을 securityContextHolder에서 가져오면 된다.

그래서 JWT는 session을 Stateless하게 관리하지만, JWT를 가지고 요청이 들어오면, 일시적으로 Session을 만들기 때문에 이 Session으로 사용자 정보를 꺼낼수 있습니다.

여기서 일시적이란, 사용자의 요청이 서버측에 진입하여, Authentication 객체를 만든뒤 응답 될때 까지입니다.
즉, 하나의 요청에서 응답까지 입니다.

CORS 설정
일단 이문제가, 프론트단은 서버를 3000번을 쓰고 벡앤드 단은 서버를 8080번으로 써서 이게 안맞아서 생기는 문제인데,

MVCConfig와 SecurityConfig둘다 처리를 해줘야한다.

SecurityConfig에서 cors추가

//Cors 설정
        http
                .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {

                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                        CorsConfiguration configuration = new CorsConfiguration();

                        configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
                        configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setMaxAge(3600L);

                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));

                        return configuration;
                    }
                })));

프론트단 3000번 허용,그리고 허용시간,그리고 토큰이 Authorization에 들어가니까 이것도 허용해줘야한다.

CorsMvcConfig등록

WebMvcConfigurer를 구현할것이고,
addCorsMappings메서드를 구현할건데, 3000번에서 오는 이 주소에서 오는걸 허가하는?거같다.

참고: 저는 다른 프로젝트에서,@CrossOrigin(origin="", allowedHeaders = "")이런식으로 했는데 그때 JWT쓴건 제대로 작동했어요. 지금 하고 있는 프로젝트를 react로 앞단 만들고있는데 나중에 직접해보고, 다시 글 수정

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글