JWT 로그인을 구현하면서 이해한 내용을 정리해보았다.
인프런에서 제공하는 아래 무료 강의를 보고 작성한 코드이다.
[무료] 스프링부트 시큐리티 & JWT 강의 - 인프런 | 강의
스프링부트 시큐리티에 대한 개념이 잡힙니다., 스프링부트 시큐리티,제가 공부했던 걸 쉽게 설명드릴게요 🙂 스프링부트 시큐리티 github https://github.com/codingspecialist/-Springboot-Security-OAuth2.0-V3 htt
www.inflearn.com
나는 프론트엔드 부분을 추가했기 때문에 JWT 필터 부분은 강의와 같지만 컨트롤러가 좀 다르다.
그리고 회원 엔티티도 강의와 다르게 만들었기 때문에 UserDetailsService 쪽도 차이가 있을 것이다.
이 글은 그냥 JWT 구현 방식의 구조에 대해 이해하기 위해서 참고만 하면 좋을 것 같다. 그래도 아무것도 모르고 코드를 따라만 치던 내가 이걸 이해한 끝에 작성한 글이니 읽어보면 도움은 될 것이라고 믿는다.
코드 설명 전 전체적인 구조를 파악하고 가는 것이 좋을 것 같아 정리해보았다.
1. 필터 구조
가장 핵심이 되는 시큐리티 필터 구조이다.
Spring Security 필터 중 Authentication과 관련된 필터는 일부이고, 그 중 가장 먼저 등장하는 것이 UsernamePasswordAuthentication filter이다. 그 앞에 JWT 필터를 끼워넣었다.
왜 JWT 필터를 제일 앞에 넣었나?
JWT 토큰을 가지고 있는 사용자는 바로 인증을 받을 수 있도록 하기 위해서이다.
JWT 토큰의 장점 중 하나가 바로 DB에 접근하지 않아도 된다는 점이다. 처음에 인증된 사용자에게만 토큰을 발급해주기 때문에, 토큰과 함께 들어온 요청에 대해서는 그 토큰의 유효성만 판단하면 될 뿐, 사용자 정보를 대조해볼 필요는 없다. UsernamePasswordAuthentication filter는 DB에 접근하여 사용자 정보를 대조하는 필터이기에 이 앞에 넣은 것이다.
그럼 JWT 토큰으로 인증이 이루어지니까 UsernamePasswordAuthentication filter는 필요없는 것 아닌가?
토큰 유효 기간이 만료된 회원은 회원가입/로그인을 통해 인증받은 후 JWT 토큰을 새로 발급받아야 한다.
이 때의 인증을 위해 UsernamePasswordAuthentication filter가 필요하다.
그런데 여기서는 UsernamePasswordAuthentication filter를 사용하지는 않았다.
대신 컨트롤러에서 이 필터가 하는 역할을 똑같이 수행하도록 했다. 필터의 역할은 Authentication 객체를 만들어 AuthenticationManager에게 넘겨주는 것인데, 컨트롤러에서 이 과정을 수행하도록 했다.
2. UsernamePasswordAuthentication 필터
이 필터는 SecurityFilterChain에 원래 등록된 필터 중 하나로, 인증과 관련된 필터들 중 제일 앞에 등장한다.
이 필터에서 UsernamePasswordAuthenticationToken이라는 Authentication 타입 객체를 만들어 AuthenticationManager을 호출하면, 내부의 AuthenticationProvider가 DB에 저장된 회원정보를 대조하여 인증을 해줄지 말지 결정한다.
아직 이런 기초 개념에 익숙하지 않으면 아래 글을 읽어보고 오면 더 좋을 것 같다.
https://bbazackcoding.tistory.com/9
[Spring] Spring Security 기본 개념-1. Authentication, Authentication Manager & Provider (+User, UsernamePasswordToken)
JWT 로그인을 구현하면서 너무 많은 개념이 등장해 이해하기 힘들었다. 그래서 Spring 공식 문서를 읽고 이해한 내용을 정리해보았다. 우선 스프링 시큐리티에서 인증이 일어나는 전체 과정을 한
bbazackcoding.tistory.com
이렇게 진행되는 과정을 컨트롤러에서 똑같이 수행하도록 했다.
그래서 내가 해줘야 할 일은
1. AuthenticationProvider 수정하기
2. 인증이 된 사용자에게 JWT 토큰 발급해주기
2. 구현 : AuthenticationProvider 수정하기 - CustomUserDetailsService
이 과정을 거친다는 것은
1. JWT 토큰이 없거나 만료된 기존 회원이 로그인
2. 비회원이 접근을 시도
둘 중 하나이다.
따라서 DB 조회를 통해 로그인을 시도한 사용자가 회원인지를 확인해야 한다. 이 과정은 DAOAuthenticationProvider에서 일어난다. DAOAuthenticationProvider은 UserDetailsService를 통해 접근한 사용자를 DB에서 검색하여 대조하고 인증을 진행한다.
이 과정에서 DB에서 사용자 정보를 찾기 위해 UserDetailsService에 있는 loadUserByUsername메서드를 사용한다. 얻어온 사용자 정보를 담기 위한 일종의 DTO로서 사용되는 것이 바로 UserDetails 타입 객체이다.
본인이 등록한 회원 엔티티의 특성에 따라 UserDetails와 UserDetailsService를 커스텀하여 사용할 수 있다.
나는 UserDetails은 Spring에서 제공하는 User을 사용했는데, 이는 이름과 비밀번호만 저장 가능하다.
그리고 나는 회원 엔티티의 이름 속성을 nickname으로 저장했기에, loadUserbyUsername 쿼리문을 새로 짜야해서
UserDetailsService만 따로 커스텀했다.
아래는 코드 전문이다.
@Component("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
System.out.println(username);
return memberRepository.findOneWithAuthoritiesByNickname(username)
.map(member -> createUser(username, member))
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createUser(String username, Member member) {
List<GrantedAuthority> grantedAuthorities = member.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
member.getNickname(),
bCryptPasswordEncoder.encode(member.getPw()),
grantedAuthorities
);
}
}
loadUserbyUsername은 memberRepository에서 username을 가지는 회원을 찾고, 이 회원의 정보를 바탕으로 user을 생성한다. user을 생성하는 함수로 추가로 createUser을 정의해주었다.
자 이제 이 파트에서 더 건드릴 것은 없다.
DB에서 사용자 정보를 제대로 담아올 수 있는 방법을 제공했으니, Provider에서 알아서 정보를 비교하고 인증을 해줄지 말지 결정할 것이다. 여기서 인증에 성공했다면 사용자에게 JWT 토큰을 발급하여 돌려주면 된다.
3. 구현 : 인증된 사용자에게 JWT 토큰 발급하기 - 컨트롤러 생성
이제 컨트롤러를 살펴보자.
사용자가 입력한 아이디와 비밀번호로 UsernamePasswordAuthenticationToken을 만든다. 이 역시 Authentication 타입인데, username과 password만 담는 간단한 꼴이다.
이 토큰을 AuthenticationManager로 넘기면 2번에서 봤던 DAOAuthenticationProvider가 인증여부를 결정해준다. 인증에 성공하면 AuthenticationManager은 Authentication 객체를 리턴하는데, 이를 SecurityContextHolder에 저장해준다.
@PostMapping("/LoginForm")
public ResponseEntity<TokenDTO> processingLoginForm(@RequestBody MemberDTO memberDTO) throws Exception {
//일단 usernamepasswordtoken을 발행하는데, 이는 아직 권한 부여가 되지 않은 토큰.
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(memberDTO.getNickname(),memberDTO.getPw());
//이 토큰에게 권한을 부여해줌
Authentication authentication=authenticationManagerBuilder.getObject().authenticate(usernamePasswordAuthenticationToken);
//context holder 에 이 유저에 대한 정보를 저장함.
SecurityContextHolder.getContext().setAuthentication(authentication);
//이제 권한을 부여받은 이 토큰을 가지고 jwt를 생성해줌.
String jwt= tokenProvider.createToken(authentication);
HttpHeaders httpHeaders=new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER,"Bearer"+jwt);
return new ResponseEntity<>(new TokenDTO(jwt), httpHeaders, HttpStatus.OK);
}
그리고 이 Authentication 객체를 가지고 JWT 토큰을 생성한다.
생성된 JWT 토큰은 헤더에 담겨 다시 리턴된다.