**로그인 인증 정보 역직렬화(Deserialization)를 위한 LoginDTO 클래스 생성**
- LoginDTO 클래스는 클라이언트가 전송한 Username/Password 정보를 Security Filter에서 사용할 수 있도록 역직렬화(Deserialization)하기 위한 DTO 클래스
@Getter
public class LoginDto {
private String username;
private String password;
}
**JWT를 생성하는 JwtTokenizer 구현**
- 로그인 인증에 성공한 클라이언트에게 JWT를 생성 및 발급하고 클라이언트의 요청이 들어올 때 마다 전달된 JWT를 검증하는 역할
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
**로그인 인증 요청을 처리하는 Custom Security Filter 구현**
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) {
Member member = (Member) authResult.getPrincipal();
String accessToken = delegateAccessToken(member);
String refreshToken = delegateRefreshToken(member);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
private String delegateAccessToken(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", member.getEmail());
claims.put("roles", member.getRoles());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
**Custom Filter 추가를 위한 SecurityConfiguration 설정 추가**
- Spring Security에서는 개발자가 직접 Custom Configurer를 구성해 Spring Security의 Configuration을 커스터마이징(customizations) 할 수 있다.
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
..exceptionHandling()
.authenticationEntryPoint(new MemberAuthenticationEntryPoint())
.accessDeniedHandler(new MemberAccessDeniedHandler())
.and()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll()
.antMatchers(HttpMethod.POST, "/*/members").permitAll()
.antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER")
.antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")
.anyRequest().permitAll()
);
return http.build();
}
...
...
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { // (2-1) AbstractHttpConfigurer 를 상속하는 타입과 HttpSecurityBuilder 를 상속하는 타입을 제너릭 타입으로 지정
@Override // (2-2) Configuration을 커스터마이징
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
// (2-3) getSharedObject() 를 통해서 Spring Security의 설정을 구성하는 SecurityConfigurer 간에 공유되는 객체를 얻을 수 있습니다.
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // (2-4) AuthenticationManager와 JwtTokenizer를 DI
jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); //성공시
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); // 실패시
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils); // (2) 추가
builder.addFilter(jwtAuthenticationFilter); // (2-6) addFilter() 메서드를 통해 JwtAuthenticationFilter를 Spring Security Filter Chain에 추가
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); // (3)추가
}
}
}
SessionCreationPolicy.*ALWAYS*
SessionCreationPolicy.NEVER
- 세션을 생성하지 않지만 만약에 이미 생성된 세션이 있다면 사용합니다.
SessionCreationPolicy.*IF_REQUIRED*
SessionCreationPolicy.*STATELESS*
- 세션을 생성하지 않으며, SecurityContext 정보를 얻기 위해 결코 세션을 사용하지 않습니다.
**AuthenticationSuccessHandler 구현**
@Slf4j
public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler { // (1)
// (2)
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// 인증 성공 후, 로그를 기록하거나 사용자 정보를 response로 전송하는 등의 추가 작업을 할 수 있다.
log.info("# Authenticated successfully!");
}
}
**AuthenticationFailureHandler 구현**
@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
log.error("# Authentication failed: {}", exception.getMessage());
sendErrorResponse(response);
}
private void sendErrorResponse(HttpServletResponse response) throws IOException {
Gson gson = new Gson();
ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
}
}
**AuthenticationFailureHandler 구현**
- AuthenticationFailureHandler는 별도의 코드를 추가하지 않아도 로그인 인증에 실패하면 알아서 호출된다.
@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
log.error("# Authentication failed: {}", exception.getMessage());
sendErrorResponse(response);
}
private void sendErrorResponse(HttpServletResponse response) throws IOException {
Gson gson = new Gson();
ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
}
}
JWT를 이용한 자격 증명 및 검증 구현
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization");
return authorization == null || !authorization.startsWith("Bearer");
}
private Map<String, Object> verifyJws(HttpServletRequest request) {
String jws = request.getHeader("Authorization").replace("Bearer ", "");
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
return claims;
}
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("username");
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
- (6) shoulNotFilter
- JWT가 Authorization header에 포함되지 않았다면 JWT 자격증명이 필요하지 않은 리소스에 대한 요청이라고 판단하고 다음(Next) Filter로 처리를 넘기는 것입니다.
- 만일 JWT 자격 증명이 필요한 리소스 요청인데 실수로 JWT를 포함하지 않았다 하더라도 이 경우에는 Authentication이 정상적으로 SecurityContext에 저장되지 않은 상태이기 때문에 다른 Security Filter를 거쳐 결국 Exception을 던지게 될 것
**JwtVerificationFilter에 예외 처리 로직 추가**
- 서명(Signature) 검증에 실패할 경우, throw되는
SignatureException
에 대해서 처리
- JWT가 만료될 경우, 발생하는
ExpiredJwtException
에 대한 처리
public class JwtVerificationFilter extends OncePerRequestFilter {
...
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (SignatureException se) {
request.setAttribute("exception", se);
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
- Exception을 catch한 후에 Exception을 다시 throw 한다든지하는 처리를 하지 않고, 단순히 request.setAttribute()를 설정
- 예외가 발생하게되면 SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않습니다.
- SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않은 상태로 다음(next) Security Filter 로직을 수행하다보면 결국에는
AuthenticationException
이 발생하게 되고, AuthenticationEntryPoint가 처리
**AuthenticationEntryPoint 구현**
AuthenticationException
이 발생할 때 호출되는 핸들러 같은 역할
AuthenticationException
이 발생할 경우 처리하고자 하는 로직을 commence()
메서드에 구현
@Slf4j
@Component
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Exception exception = (Exception) request.getAttribute("exception");
ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED);
logExceptionMessage(authException, exception);
}
private void logExceptionMessage(AuthenticationException authException, Exception exception) {
String message = exception != null ? exception.getMessage() : authException.getMessage();
log.warn("Unauthorized error happened: {}", message);
}
}
ErrorResponder
public class ErrorResponder {
public static void sendErrorResponse(HttpServletResponse response, HttpStatus status) throws IOException {
Gson gson = new Gson();
ErrorResponse errorResponse = ErrorResponse.of(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(status.value());
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
}
}
**AccessDeniedHandler 구현**
- 인증에는 성공했지만 해당 리소스에 대한 권한이 없을 경우 호출되는 핸들러입니다.
AccessDeniedException
이 발생하면 ErrorResponse를 생성해서 클라이언트에게 전송
@Slf4j
@Component
public class MemberAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ErrorResponder.sendErrorResponse(response, HttpStatus.FORBIDDEN);
log.warn("Forbidden error happened: {}", accessDeniedException.getMessage());
}
}