[Spring] JWT로 인증 구현하기
서버는 인증된 클라이언트에게 토큰을 발급해주고 클라이언트는 인증이 필요한 API를 사용할 때마다 발급받은 토큰을 헤더에 담아 요청을 보내야 한다. 스프링 프로젝트에 **JWT(Json Web Token)**를 적용해보는 과정을 담았다.
JWT 설정
build.gradle
JWT를 사용하기 위해 build.gradle에 다음 내용을 추가해주고 generate 해주자.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.0'
PrincipalDetails
현재 사용자 정보를 가져올 수 있도록 하는 PrincipalDetails이다. UserDetails를 구현하면서 다음 함수들을 같이 구현하는데 getAuthorities()에서 현재 사용자의 권한 정보를 반환해줄 수 있도록 설정해주자.
@Data
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
//현재 사용자가 갖고있는 권한들을 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
만약 User 엔티티가 권한 정보를 담는 필드를 갖고있지 않다면 아래 코드처럼 설정해주자.
@Entity @Getter
@NoArgsConstructor(access = PROTECTED)
public class User {
...
@NonNull
@ElementCollection(fetch = EAGER)
private List<String> roles = new ArrayList<>();
...
}
@ElementCollection: 해당 필드가 여러 값을 담을 수 있도록 하며, fetchType 기본값이 LAZY임- 위처럼 설정할 경우
user_roles라는 테이블이 생성되고user테이블과 연관관계를 맺게 됨
JwtTokenProvider
토큰 생성, 토큰에서 인증 정보 조회, PK 조회 그리고 토큰 유효성 검사 기능을 제공하는 JwtTokenProvider을 정의해주자.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private String secretKey = "secret-value";
private long tokenValidTime = 30 * 60 * 1000L; //토큰 유효시간
private final UserDetailsService userDetailsService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
//토큰 생성
public String createToken(String email, List<String> roles) {
//JWT에 저장할 정보 생성
Claims claims = Jwts.claims().setSubject(email);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) //정보 저장
.setIssuedAt(now) //토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) //토큰 유효시간
.signWith(SignatureAlgorithm.HS256, secretKey) //암호화
.compact();
}
//인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
//회원 PK 조회
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
//토큰 유효한지 확인
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
//request의 header에서 토큰 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
}
init():@PostConstruct에 의해 서비스 실행 전에secretKey를 초기화createToken(): 파라미터email,roles로 토큰 생성 후 반환User의 PK가email이어서 파라미터로 설정
getAuthentication(): 토큰에서 인증 정보 조회getUserPk(): 토큰에서 현재 회원의 PK 정보 반환validateToken(): 토큰 유효성 검사resolveToken(): request의 헤더에서 토큰 반환- 토큰을 헤더의
Authorization에 입력하는 경우를 예시로 설정했음 - 이 때
Authorization에는Bearer {token 값}이 아닌{token 값}만 들어가야 함
- 토큰을 헤더의
JwtAuthenticationFilter
HTTP 요청이 들어오면 가장 먼저 거치게 될 필터 JwtAuthenticationFilter를 정의해주자. jwtTokenProvider로 토큰을 가져오고, 검증하고 유저 정보를 얻어서 SecurityContext에 저장한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
//헤더에서 토큰 가져오기
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token); //유저 정보
SecurityContextHolder.getContext().setAuthentication(authentication); //SecurityContext에 객체 저장
}
//다음 Filter 실행
chain.doFilter(request, response);
}
}
JwtTokenProvider는 주입받아서 사용
Spring Security
스프링 시큐리티 설정을 담고있는 SecurityConfig 클래스다. filterChain()에서 위에서 정의했던 JwtAuthenticationFilter와 JwtTokenProvider를 적용해주자. 이 때 JwtTokenProvider는 주입받아 사용한다. JWT 발급 및 인증 필터를 적용할 수 있다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final JwtTokenProvider jwtTokenProvider;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable())
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http
.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
//로그인 및 회원가입 API를 인증없이 사용할 수 있도록 설정
.requestMatchers("/api/login", "api/signup").permitAll()
//이외 모든 접근은 인증 필요
.anyRequest().authenticated()
)
.formLogin((formLogin) ->
formLogin
.usernameParameter("email")
.passwordParameter("password")
.loginPage("/login")
.failureUrl("/login?failed")
.loginProcessingUrl("/login/process")
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public static BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)를 통해 세션 설정 해제formLogin()을 사용하지 않는 경우(=별도 로그인 페이지를 지원할 경우)formLogin(AbstractHttpConfigurer::disable)로 설정UsernamePasswordAuthenticationFilter: Spring Security에서 인증 처리를 담당하는 필터
로그인 API 테스트
DTO
사용자 email과 password를 받을 LoginDto를 정의해주자.
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
private String email;
private String password;
}
- 만약
@AllArgsConstructor를 설정해두고 기본 생성자(@NoArgsConstructor)를 설정해주지 않으면 테스트 시 정상적으로 input이 넘어오지 않으므로 주의
Service
service 클래스에는 로그인 처리를 해줄 login() 함수를 정의해주자.
public User login(LoginDto loginDto) {
User user = userRepository.findByEmail(loginDto.getEmail())
.orElseThrow(() -> new NoResultException("User doesn't exist"));
return passwordEncoder.matches(loginDto.getPassword(), user.getPassword()) ? user : null;
}
- 입력받은
email과password로 DB에 저장된 회원 정보와 일치하는지 체크 password비교 시BCryptPasswordEncoder.matches()사용loginDto의password를 인코딩한 것과 DB에 저장된 이전에 인코딩된password를 비교하면 안됨!
Controller
컨트롤러에서는 JwtTokenProvider를 주입받아 로그인에 성공한 인증된 사용자에게 새 토큰을 발급받고 반환한다. 토큰을 발급하는 과정에서 email과 roles를 파라미터로 넘겨준다.
@RestController
@RequiredArgsConstructor
public class SignController {
private final UserService userService;
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/api/login")
public String loginFromAPI(@RequestBody LoginDto loginDto) {
User user = userService.login(loginDto);
return user != null ?
jwtTokenProvider.createToken(user.getEmail(), user.getRoles()) : "일치하는 유저 정보가 없습니다.";
}
ID/PW를 json 형태로 전달해 테스트할 것이므로 파라미터 LoginDto는 @RequestBody로 받아야 한다.
Postman에서 테스트
로그인에 성공 후 응답으로 토큰이 오는걸 확인할 수 있다😄