[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에서 테스트
로그인에 성공 후 응답으로 토큰이 오는걸 확인할 수 있다😄
댓글남기기