JWT는 JMT
이번시간부터 JWT 토큰을 활용하여 로그인, 회원가입등을 처리하는 서비스를 만들어 보겠습니다.
이 글은, Youtube 개발자 유미채널에서 JWT 영상을 보고 작성하는 글 입니다.
또한 https://mangkyu.tistory.com/57글과 다른 여러 글들을 참고하여 제가 이해한 내용을 바탕으로 작성된 글입니다.
해당 링크는 하단에 남겨두도록 하겠습니다.
JWT 인증 방식 시큐리티 동작원리
그래서 어떤 특정한 경로로 요청이 오면,
만약에 토큰이 알맞게 존재하고 토큰 내부에 정보가 일치하면,
JWT Filter에서 강제로 일시적인 Session을 SecurityContextHolderSession에서 만들어서 session이 있기 때문에 특정한 admin경로에 들어가던지 할 수 있다.
다만 이러한 경우, session은 항상 stateless(무상태)로 관리하기때문에, 다른 요청이 들어오면, 그 헤더에 있는 토큰을 통해서 동일한 아이디더라도 Session을 새롭게 만들고 그 요청이 끝나면 사라지는 방식으로 동작한다.
프로젝트 만들기
데이터베이스 의존성 주석처리
스프링 부트에서 데이터베이스 의존성을 추가한 뒤 연결을 진행하지 않을경우 런타임 에러가
발생하므로, 임시로 주석처리 후 진행
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을 Statless상태로 설정합니다.
왜냐하면 스프링 시큐리티는 Form Login 인증방식을 제공하는데
대략적으로 그림을보면,
이런식으로 username과 password를 통해서 검증을 마치면, session으로 유저정보를 저장해둡니다.
그런데, 우리는 Session을 사용하지않고 JWT를 사용하기 때문에 이 기능을 disable시켜놓은것입니다.
또한 session방식에서는 session이 고정되기 때문에 csrf공격을 방어해야합니다. 그러나 jwt에서는 우리는 session을 stateless상태로 설정하기때문에 csrf를 disable시킵니다.
그리고 Form login방식을 비활성화합니다.
사용자가 로그인 폼에 자신의 아이디와 비밀번호를 입력하면, 서버에서 이 정보로 사용자를 인증합니다. 이 과정에서 스프링 시큐리티는 사용자 세션을 생성하고 관리하여 로그인 상태를 유지하게 되는데, 우리는 스프링 시큐리티가 기본적으로 제공하는 로그인 폼을 사용하지 않고 JWT와 같은 토큰 기반 인증을 사용하기 위함입니다.
그래서 서버에서는 클라이언트 상태(로그인 상태인지 아닌지)를 유지 하지 않습니다.
대신, 클라이언트는 매 요청마다 서버에게 자신이 누구인지 증명하는 JWT토큰을 전달합니다.
따라서 JWT를 사용할때는 세션을 생성하고 관리할 필요가 없으므로, Form Login방식도 필요하지 않습니다.
즉, 폼 로그인을 비활성화시키면, 스프링 시큐리티는 자동으로 생성되는 로그인 페이지를 제공하지 않게 됩니다.
다음은 경로별 인가작업을 분리해야합니다.
/login,/,/join과 같은경우에는 당연히 로그인을 할 수 없는 상태에서 접근해야하므로, 접근허용을 해주고
그다음 /admin으로 접근시에는 role이 admin인지 확인
그리고 나머지 anyRequest에는 로그인을 해야지만 접근이 가능하게 하였습니다.
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 아키텍쳐 문서
스프링 시큐리티 필터 동작 원리
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메서드들을 작성한다.
사용자로부터 받은 id와 패스워드를 가지고 AuthenticationManager한테 넘겨줘서 정상적인 id,password인지 검증을 받아야한다.
그냥 넘기면안되고, DTO처럼 바구니에 담아야하는데 그게 바로 UsernamePasswordAuthenticationToken이다.
만든 토큰을 검증하기위해서 AuthenticationManger의 authenticate 메서드를 호출할때 파라미터로 넘겨줘서 검증을 진행한다.
그렇다면 UsernamePasswordAuthenticationFilter의 attemptAuthentication은 누가 호출해주는 것일까?
요청이 들어오면 -> AbstractAuthenticationProcessingFilter의 dofilter실행 -> UsernamePasswordAuthenticationFilter을 구현한 LoginFilter에서 attemptAuthentication실행
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
참고로 AuthenticationManager도 DI받아서 사용해야한다.
그러면 AuthenticationManager가 검증을 담당하는데
검증이 성공하면, successfulAuthentication 메서드를 실행시키고,
실패하면 unsuccessfulAuthentication 메서드를 실행시킨다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
}
//로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
}
이것도 동일하게 로그인 필터 안에 있다.
우리가 새로만든 필터도 등록을 해야 당연히 사용할 수 있을것 아닌가?
등록해주자.
SecurityConfig에 추가
그런데 보면 LoginFilter를 등록시키는데 우리가 LoginFilter class가보면, Authentication을 주입받았다. 고로
여기서 파라미터로 넘겨주려면 여기 securityConfig에서도 DI받아야한다.
그런데 여기 AuthenticationMananger에서도 파라미터로 AuthenticationConfiguration 을 받아야한다.
그래서 결국, SecurityConfig에서 AuthenticationConfiguration을 DI해준다.
지금까지 UsernamePasswordAuthenticationFilter에서 검증되지 않은 토큰을 authenticate메서드 호출을 통해 AuthenticationManager에 넘겨주기까지 했다.
이제 AuthenticationManager가 실제 DB에서 User정보를 끌어와서 토큰과 비교를 하는 작업을 해야한다.
UserRepostiroy
DB에서 User정보를 가져오기 위해서 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService를 만든다.
CustomUserDetailsService
UserDetailsService를 imple하게 되면 반드시 loadUserByUSername을 오버라이딩 해야만 합니다.
그래서 userRepository에서 위에서 정의한 ORM으로 UserEntity를 찾고,
그다음에 null이 아니라면, 위에 모식도와같이 UserDetails를 만들어서 넘겨줘야한다.
만약 DB에서 가져오려했는데 해당 UserEntity가 없다면 null을 반환한다.
CustomUserDetails
UserDetails도 인터페이스이기 때문에 해당 인터페이스를 구현한 CustomUserDetails를 만든다.
여기서는 DTO와 비슷하게 UserEntity에대한 정보가 들어있다.
그래서 get방식으로 password,username,만료되었는지아닌지 ,등등을 확인 할 수 있다.
여기에서 왜 UserDtails를 인터페이스로 만들었는지 알 수 있다. 개발 환경에 따라 id,password뿐만아니라, 나는 user엔티티에 otp라는 String 필드가 있고 해당 필드까지 동일해야 검증하고 싶다고 하면,
여기서 getOtp()라는 메서드도 만들 수 있다.
다형성을 통해서 개발을 확장할 수 있다.
메서드 선언
getAuthorities() 메서드는 Collection<? extends GrantedAuthority> 타입을 반환한다. 이는 Spring Security에서 사용자의 권한 목록을 의미한다. GrantedAuthority는 사용자의 권한을 나타내는 인터페이스이다.
권한 컬렉션 생성
ArrayList의 인스턴스를 생성하여 collection 변수에 할당합니다. 이 컬렉션은 나중에 반환될 사용자의 권한들을 담게 됩니다.
익명 클래스를 이용한 GrantedAuthority 구현
collection에 GrantedAuthority의 익명 구현체를 추가합니다. 이 구현체는 getAuthority() 메서드를 오버라이드하여, 실제 사용자의 권한을 나타내는 문자열을 반환합니다.
여기서 userEntity.getRole()은 사용자 엔티티에서 사용자의 역할(권한)을 가져옵니다. 이 역할은 GrantedAuthority 객체에 의해 권한으로 사용됩니다.
여기서부터 진짜 집중
여기까지 왔으면 대충 이게 말이되나? 싶으면 아주 정확하게 여기까지 글을 이해한것이다.
우리는 지금 그림과 같이 AuthenticationManger에다가 검증되지 않은 토큰만 넘겨줬을 뿐이다.
그러면, 도대체 어디서 AuthenticationManager가 DB에서 유저를 끌어오고
또 어디서 해당 User정보와 토큰을 검증하는지 살펴보도록 하겠다.
아까 LoginFilter에서 attempAuthentication필터가 실행될때 AuthenticationManager에게 authenticate메서드를 호출한다고 했다.
이것을 고급지게 AuthenticationManger에게 인증 로직을 위임한다고 한다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//...
return authenticationManager.authenticate(authToken);
}
잠깐 토큰에 대해서 설명하자면,
UsernamePasswordAuthenticationToken에서 생성자가 두가지 이다.
생성자의 파라미터가 2개인것은 인증이되지 않은 토큰
생성자의 파라미터가 3개인것은 role이 포함된 인증된 토큰이다.
그럼 토큰이 뭘까? usernamePasswordAuthenticatioNToken으로 가보자.
UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받는다.
다시 AbstractAuthenticationToken을 보면 Authentication을 상속받고 있다.
즉 UsernamePasswordAuthenticationToken은 나중에 인증이 되면, SecurityContextHolder.getContext()를 통해 등록될 Authentication객체인것이다.
다시 위로 돌아가서 attempAuthentication에서 return 부분을 보면 this.getAuthenticationManager().authenticate(authRequest)부분을 보자.
그러면, 위에서 만들었던, 인증이 되지않은 UsernamePasswordAuthenticationToken을 인자로 넣고 있다.
LoginFilter를 보면 우리는 AuthenticatoinManager를 주입받아서 사용하고 있다.
그러면 AuthenticationManager를 봐보자.
AuthenticationManager는 인터페이스로 되어있고, authenticate메서드만 정의 되어있다.
AuthentiationManager는 AuthenticationProvider라는 클래스 객체를 관리한다.
즉 AuthenticationProvider에 실제 인증 로직이 담겨 있는 객체라고 보면된다.
그렇다면 AuthenticationManager가 인터페이스니까 UsernamePasswordAuthenticationFilter에서 사용하는 AuthenticationManager의 구현체는 Providermanager클래스이다.
기본적으로 spring security는 이 기본 Providermanager를 사용한다.
실제 Providermanager 클래스에 가보면 AuthenticationManager를 상속하고 있는걸 볼 수 있다.
결국 마지막 return문은 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를 가져오는거였다.
당연히 UserDetails는 userRepository에서 그냥 이름가지고 찾아온거다.
이제 authentication(검증되지 않은 토큰)과 UserDetails가 동일한지 검사해야한다.
다시 AbstractUserDetailsAuthenticationProvider로 돌아와서 additionalAuthenticationChecks메서드를 호출해서 검증한다.
해당 메서드도 추상메서드 이므로, 메서드를 구현한
그러면 다시 DaoAuthenticationProvider로 넘어가서 확인하면
드디어 autentication과 UserDetails의 username이 동일한지 확인하고 있다.
그럼 비밀번호는? 검사를 안하나? 싶었는데 찾아보니까
AuthenticationManager의 authenticate() 메서드 내부 구현을 따라가보면, 미리 빈으로 등록해 둔 PasswordEncoder로 UserDetails의 password와 matching 작업이 구현되어 있어서 올바르게 비밀번호 검증이 된다. 라고 한다.
어쨋든 이렇게 검증을 끝내고, 동일하다면 이제 마지막으로 AbstractDetailsAuthenticationProvider의 authenticate()마지막 부분에 createSuccessAuthentication(principalToReturn, authentication, user)가 반환된다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
//username을 가지고 UserDetails를 가져옴
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
//로직
}
try {
this.preAuthenticationChecks.check(user);
//여기서 UserDetails랑 토큰이랑 같은지 검증
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
}
//이제 검증이 완료되었으므로 검증된 토큰 발급
return createSuccessAuthentication(principalToReturn, authentication, user);
}
그리고 AbstractUserDetailsAuthenticationProvider의 createSuccessAuthentication을 가면,
마침내 생성자 3개짜리 UsernamePasswordAuthenticationToken 생성자를 호출하고 있다.
이전에도 Authentication객체를 만들긴 했는데 이건 인증이 안된거고, 결국
타고타고 가다가, 마지막에 3개짜리 authorities를 설정한, 인증된 Authentication객체를 만든것이다.
이걸 그럼 어디다 반환하냐? 우리가 맨처음으로 다시올라가다보면, AbstractAuthenticationProcessingFilter의 doFilter에서 실행된 것이었다.
결국 attempAuthentication메서드를 통해서 우리는 검증된 Authentication 즉, UsernamePasswordAuthenticationToken을 가지게 되었고 마지막으로 successfulAuthentication메서드를 호출해준다.
우리는 UsernamePasswordAuthenticationFilter를 상속받은 LoginFilter에다가 successfulAuthentication메서드를 만들었으므로 해당 successfulAuthentication메서드가 실행된다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
//유저 정보
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
//토큰 생성
String access = jwtUtil.createJwt("access", username, role, 7200000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//Refresh 토큰 저장
addRefreshEntity(username, refresh, 86400000L);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
여기서 넘어온 Authentication객체는 인증이 완료된 3개의 파라미터로 생성된 토큰이고 이걸통해서 쿠키를 만들어서 응답을한다.
휴... 길지만 여기까지가 로그인시에 username과 password를 검증한는 인증작업이다.
이후로 우린 이 검증후에 JWT발급과 더불어 인가 작업도 알아보겠다.
Jwt 발급
지금 보면 UserDetailsService에서 DB에서 해당 UserEntity가져와서 UserDetails 바구니에 넣어서 AuthenticationManager에서 검증을 한 상태이다.
만약에, 정상적이라서 JWT를 발급해야하는 경우를 살펴보겠다.
JWT발급과 검증
로그인시 -> 성공 -> JWT 발급
접근시 -> JWT 검증
JWT 생성원리
JWT 공식문서
JWT Encoded부분을 보면 .을 기준으로 3가지 파트로 나눌 수 있는데
첫번째, header 두번째 payload 세번째 signiture
★★★★★★★ JWT의 특징은 내부정보를 단순히 BASE64 방식으로 인코딩 하기 때문에 외부에서 쉽게 디코딩이 가능하다. 그러므로, 외부에서 열람해도 되는 정보를 담아야한다.
그러면 어차피 비밀번호도 못넣는데 이걸 왜쓰냐?
바로, 토큰 자체의 발급처를 확인하기 위함이다.
이게 내가 정상적으로 발급한 토큰이라서 이 토큰을 검증해도 되는가? 이걸 확인하는거다.
=> 지폐와 같이 외부에서는 그 금액과 외형을 따라 위조 할 수 있지만, 원본 위조는 힘들다, 엄청 그 안에 홀로그램이라던지 위조 방지 기능이 있으니까
고로, 금액이 중요한게 아니고 지폐 자체, 즉 토큰자체가 내가 발급한지 확인하는것이다.
고로, 토큰 내부에 비밀번호같은 값을 입력하면 안된다.
그래서 만약에 해커가 JWT를 payload부분에 ROlE를 일반인이아니라, admin으로 설정하더라도, signiture부분에서 토큰이 내가 발급한게 아니라는것을 알 수 있기때문에 반려된다.
Jwt 암호화 방식
암호화 종류
우리는 양방향 대칭키를 사용할 것임
암호화 키 저장
JWT Util
이제 JWT토큰을 만들 JWTUtil class를 만든다.
여기서 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등록
CORS설정을 해준다.