이전에 스프링 시큐리티로 로그인, 회원가입을 구현하였는데 이번에는 jwt 를 추가해 인증, 인가를 구현해보려 한다.
장점
단점
장점
단점
세션 방식과 JWT 방식은 각각의 장단점이 있지만 우선 별도의 메모리 DB를 두기에는 학생이기 때문에 비용적으로 부담이 컸다. 또한 stateless한 특성으로 세션을 관리하지 않아도 되고, 그로 인해 부하가 발생하지 않아 대량의 트래픽이 발생해도 대처할 수 있다는 장점이 크게 다가왔고 JWT(토큰 방식)을 선택했다.
Jwt 의 동작 과정은 다음과 같다.
실제로 다음과 같이 구현할 것이고, JWT의 보안성 문제와 안전한 로그아웃이 되지 않는 단점을 극복한 방법도 블로그를 통해 작성할 예정이다.
JWT 토큰 방식을 적용하기 위해 다음 라이브러리들를 build.gradle에 추가해준다.
// Jwt
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.0'
implementation 'org.webjars.npm:jsonwebtoken:8.5.1'
implementation 'com.nimbusds:nimbus-jose-jwt:9.31'
jwt 사용을 위한 secretkey를 application.yml에 암호화하여 작성한다.
** 키가 노출되면 jwt 가 조작될 수 있기 때문에 암호화를 하여 git에 올리거나 application.yml을 .gitignore에 추가해줘야 한다.
jwt:
secret: ENC(C2bA91/W5AnYOlwQ7s2QTEbw26I0epS+)
JWT와 인증을 위해 설정 파일을 수정한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtAuthenticationEntryPoint authenticationEntryPoint;
private static final String[] WHITE_LIST = {
"/test/**",
"/swagger-resources/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/api/v1/auth/**",
"/error"
};
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws
Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(request -> request
.requestMatchers(WHITE_LIST).permitAll()
.anyRequest().authenticated() //어떠한 요청이라도 인증 필요
)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint))
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
;
return http.build();
}
import org.springframework.security.authentication.AuthenticationManager;
이전 코드
/** @Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
**/
또한 커스텀한 에러를 반환하기 위해 authenticationEntryPoint 를 적용해주었다.
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)에서 Jwt 인증을 위한 필터와 기본적인 사용자 인증을 처리하는 필터를 적용했다.
유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴한다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
JWT 토큰으로 인증하고 인증정보를 SecurityContextHolder에 추가하는 역할을 담당한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
// 토큰의 인증정보를 SecurityContext에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
// 토큰 검증
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
JWT를 생성하고 검증하는 컴포넌트이고, JWT 생성과 유효성 검사 등의 로직을 포함하고 있다. 토큰과 관련된 모든 것은 여기서 이루어진다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
// 토큰의 암호화/복호화를 위한 secret key
@Value("${jwt.secret}")
private String secretKey;
public static final String BEARER = "Bearer";
// Refresh Token 유효 기간 14일 (ms 단위)
private final Long REFRESH_TOKEN_VALID_TIME = 14 * 1440 * 60 * 1000L;
// Access Token 유효 기간 30분
private final Long ACCESS_TOKEN_VALID_TIME = 30 * 60 * 1000L;
private final MyUserDetailsService userDetailsService;
private final RedisServiceImpl redisServiceImpl;
// 의존성 주입이 완료된 후에 실행되는 메소드, secretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public JWTAuthResponse generateToken(String username, Authentication authentication, Long userId, String name) {
String id = authentication.getName();
Claims claims = Jwts.claims().setSubject(username); //사용자 아이디
claims.put("userId", userId); //사용자 UID
claims.put("name", name); //사용자 이름
Date currentDate = new Date();
Date accessTokenExpireDate = new Date(currentDate.getTime() + ACCESS_TOKEN_VALID_TIME);
Date refreshTokenExpireDate = new Date(currentDate.getTime() + REFRESH_TOKEN_VALID_TIME);
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(currentDate)
.setExpiration(accessTokenExpireDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
String refreshToken = Jwts.builder()
.setClaims(claims)
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setExpiration(refreshTokenExpireDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
redisServiceImpl.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_VALID_TIME));
return JWTAuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType(BEARER)
.accessTokenExpireDate(ACCESS_TOKEN_VALID_TIME)
.build();
}
// Token 복호화 및 예외 발생(토큰 만료, 시그니처 오류)시 Claims 객체 미생성
public Claims parseClaims(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
// 리프레시 토큰 만료 시간을 가져오는 메서드
public Long getRefreshTokenExpirationMillis() {
return REFRESH_TOKEN_VALID_TIME;
}
// Access Token의 만료 시간을 가져오는 메서드
public Long getAccessTokenExpiration(String accessToken) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(accessToken)
.getBody();
Date expiration = claims.getExpiration();
if (expiration != null) {
return expiration.getTime();
} else {
// 만료 시간이 null이면 기본값인 0 반환
return 0L;
}
}
public String getUsername(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody().getSubject();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 사용자 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", new ArrayList<>());
}
// Request의 Header로부터 토큰 값 조회
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return bearerToken;
}
// Request Header에 Refresh Token 정보를 추출하는 메서드
public String resolveRefreshToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Refresh");
if (StringUtils.hasText(bearerToken)) {
return bearerToken;
}
return null;
}
// 토큰의 유효성 검증
public boolean validateToken(String jwtToken) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return true;
} catch (SecurityException e) {
throw new JwtException("잘못된 JWT 서명입니다.");
} catch (MalformedJwtException e) {
throw new JwtException("잘못된 JWT 토큰입니다.");
} catch (ExpiredJwtException e) {
throw new JwtException("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
throw new JwtException("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
throw new JwtException("JWT 토큰의 구조가 유효하지 않습니다.");
}
}
}
AccessToken과 RefreshToken을 생성하며 각각 유효시간은 30분, 14일이다. AccessToken은 너무 짧기도 길지도 않게 설정하기 위해 30분으로 설정했다.
RefreshToken은 Redis에 저장한다.
로그인(인증) 성공 시 응답으로 전달할 JWT 관련 정보를 담는 DTO이다.
@Getter
@NoArgsConstructor
public class JWTAuthResponse {
private String tokenType;
private String accessToken;
private String refreshToken;
private Long accessTokenExpireDate;
@Builder
public JWTAuthResponse(String tokenType, String accessToken, String refreshToken, Long accessTokenExpireDate) {
this.tokenType = tokenType;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.accessTokenExpireDate = accessTokenExpireDate;
}
}
회원가입, 로그인 controller 를 작성한다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/user-service")
public class UserController {
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<JWTAuthResponse> login(@RequestBody RequestLogin requestLogin){
JWTAuthResponse token = userService.login(requestLogin);
return ResponseEntity.ok(token);
}
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody RequestUser requestUser){
String response = userService.register(requestUser);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
** 현재 개발이 모두 완료된 상태이기 때문에 이전에 작성했던 코드를 가져왔습니다. url 등이 security 설정과 맞지 않을 수 있습니다.
회원가입, 로그인 UserService 인터페이스와 UserServiceImpl를 구현한다.
public interface UserService{
JWTAuthResponse login(RequestLogin requestLogin);
String register(RequestUser requestUser);
}
@Slf4j
@Transactional
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final BCryptPasswordEncoder pwdEncoder;
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final MyUserDetailsService myUserDetailsService;
private final UserRepository userRepository;
@Override
public JWTAuthResponse login(RequestLogin requestLogin) {
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
requestLogin.getEmail(), requestLogin.getPwd()));
SecurityContextHolder.getContext().setAuthentication(authentication);
Long userId = myUserDetailsService.findUserIdByEmail(requestLogin.getEmail());
JWTAuthResponse token = jwtTokenProvider.generateToken(requestLogin.getEmail(), authentication, userId);
return token;
}
@Override
public String register(RequestUser requestUser) {
// add check for email exists in database
if(userRepository.existsByEmail(requestUser.getEmail())){
throw new BlogAPIException(HttpStatus.BAD_REQUEST, "Email is already exists!.");
}
ModelMapper mapper = new ModelMapper();
mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
UserEntity userEntity = mapper.map(requestUser, UserEntity.class);
userEntity.setEncryptedPwd(pwdEncoder.encode(requestUser.getPwd()));
userEntity.setApproved(false);
userRepository.save(userEntity);
return "User registered successfully!.";
}
}
로그인 시 빈등록을 했던 AuthenticationManager를 통해 아이디(여기선 이메일), 비밀번호를 인증하고 인증 완료 시 인증 정보를 SecurityContextHolder에 저장한다.
사용자 이메일과 인증 정보, userId를 통해 토큰을 생성한다.
사용자 정보를 저장, 조회, 수정, 삭제를 위한 UserRepository를 구현한다.
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByEmail(String email);
Boolean existsByEmail(String email);
}
회원가입 후 로그인이 잘되는 것을 확인할 수 있다.
시큐리티에 JWT 를 적용하는 방법은 생각보다 간단하다.
Security 설정 코드에서 addFilterBefore에 UsernamePasswordAuthenticationFilter보다 먼저
JwtAuthenticationFilter를 적용시키면 된다.
또한 JwtAuthenticationEntryPoint를 통해 커스텀한 에러를 반환할 수 있게 할 수 있다. 이러한 방법으로 Filter를 커스텀 하여 인증을 수행하면 된다.
다음 포스팅에서는 로그인 후 모든 요청에서 토큰을 인증하는 Jwt Intercepter를 구현하는 방법에 대해 알아보겠습니다.
https://spring.io/guides/topicals/spring-security-architecture