걸음마부터 달리기
Security와 JWT 본문
본 글은 JWT와 관련한 인증과정임
주로 UsernamePasswordAuthenticationFilter , AbstractAuthenticationProcessingFilter 와 관련된 인증필터를 집중적으로 작성함.
시큐리티 필터는 위 사진과 같다.
아주 수많은 필터들이 있고 개발하다보면 필터를 커스텀해야될때가 있다.
우리가 실질적으로 중요한건 인증과 인가에 관련된 필터들이므로 주로 UsernamePasswordAuthenticationFilter 와 이러한 Authentication(인증) 절차에서의 UserDetailService , UserDetails 에 집중해야된다.
AuthenticationProcessingFilter 와 UserpasswordAuthenticationFilter
JWT, 일반 api 로그인으로 인증을 할 것이라면 AuthenticationProcessingFilter 를 extends한 UserpasswordAuthenticationFilter 에서 모든것이 일어난다.
UserpasswordAuthenticationFilter 코드를 까보면 막상 우리가 아는 필터의 기본지식인 doFilter를 호출하는 부분은 없고 오로지 인증과 관련된 메서드만 있다.
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean {
// 필터 체인에서 호출되는 메서드
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// 매칭되는 URL이 있는 경우 인증을 시도
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
// 2. SecurityContext에서 이미 인증된 Authentication 객체가 있는지 확인
Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication();
if (existingAuth != null && existingAuth.isAuthenticated()) {
// 이미 인증된 상태라면, 필터 체인을 계속 진행 (추가 인증 처리 안 함)
chain.doFilter(request, response);
return;
}
try {
// 인증 시도: 여기서 UserPasswordAuthenticationFilter의 attemptAuthentication()가 호출됨
Authentication authResult = attemptAuthentication(request, response);
if (authResult == null) {
return;
}
// 인증 성공 시 처리
successfulAuthentication(request, response, chain, authResult);
} catch (AuthenticationException failed) {
// 인증 실패 시 처리
unsuccessfulAuthentication(request, response, failed);
}
}
// 인증 성공 시 호출되는 메서드
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
// SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authResult);
// 성공 핸들러 호출
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, authResult);
} else {
chain.doFilter(request, response);
}
}
// 인증 실패 시 호출되는 메서드
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
// 실패 핸들러 호출
if (this.failureHandler != null) {
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
}
}
package org.springframework.security.web.authentication;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class UserPasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;
public UserPasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// Check if the request is POST (if postOnly is set to true)
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
// Retrieve the username and password from the request
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
// Create an unauthenticated UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set details on the authentication request
setDetails(request, authRequest);
// Delegate the authentication to AuthenticationManager
return this.getAuthenticationManager().authenticate(authRequest);
}
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(passwordParameter);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(String usernameParameter) {
this.usernameParameter = usernameParameter;
}
public void setPasswordParameter(String passwordParameter) {
this.passwordParameter = passwordParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
보면 우선적으로 attemptAuthentication에서 인증을 수행하게 된다.
1) UserpasswordAuthenticationFilter는 시큐리티 필터들을 수행하면서 여기까지 오면 request와 response를 가지고 인증이 되지 않은 Authentication 객체를 일단 만든다. 그 후
2) 이 클래스는 Manager의 구현체인 AuthenticationProvider를 통해서 UserDetailsService 함수를 수행하게 된다. UserDetailsService 함수는 내부에서 UserDetails 객체를 만들어서 Provider에게 던져준다.
3-1) 그러면 UserDetailsService에서 Provider에게 UserDetails와 인증되지 않은 Authentication객체의 비밀번호가 동일한지 비교한다. 즉 같은 이름으로 있는(DB에) 서버의 데이터와 요청이 온 데이터를 기준으로 비교하게 되고
- 같다면 UserpasswordAuthenticationFilter. attemptAuthentication 는 인증된 Authentication으로 세팅한 후 리턴해준다.
이러면 AuthenticationProcessingFilter에 있는 doFilter 쓰레드가 attemptAuthentication 메서드 이후의 코드들을 마저 수행하고 Authentication의 Role 필드를 보고
Role이 있다면 successful Handler 메서드를 수행 + Security Context Holder를 통하여 Security Context에 인증 여부가 확정된 Authentication객체를 등록하고
Role 이 없다면 failed Handler를 수행 + Security Context에 Authentication을 등록하지 않고 넘어간다.
이 Handler 메서드에는 doFilter가 존재하여 다음 필터를 수행하게 된다.
3-2) 만약 UserDetailsService에서 Provider에게 UserDetails와 인증되지 않은 Authentication객체의 비밀번호가 동일한지 비교하고 동일하지 않는다면
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
// 비밀번호 비교 (PasswordEncoder를 사용)
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
throw new BadCredentialsException 로 예외를 던져버린다. Manager(구현체 Provider) 는 이걸 이제 AbstractAuthenticationProcessingFilter로 전달하고 이 필터가 catch문으로 잡아서 실패 메서드인 unsuccessfulAuthentication() 를 호출하게 된다.
JWT 토큰 발행
우리는 기본적으로 JWT 인증방법을 사용하면 세션을 StateLess로 설정하기에
Request에 요청이 올때 Authentication 헤더에 토큰값으로 유효한지 아닌지로 판단해야된다.
따라서 이 토큰 발행을 할때는 모든 인증이 끝난 이후 successful Handler를 이용하여 이 메서드 안에서 request 안 헤더에녹여서 보내면 발행하면 된다.
OncePerRequestFilter와 JWTAuthenticationFilter
OnceperRequestFilter는 시큐리티에서 지원하는 리다이렉션, 포워딩에서도 같은 요청이라면 한번만 거치는 필터이다. 우리가 JWT로 인증을 할 경우 리다이렉션 및 포워딩을 해도 다시 필터를 거치면서 인증할 필요는 없기에 OnceperRequestFilter를 상속받아 구현하면 된다. 그것을 나는 JWTAuthenticationFilter라고 하겠다.
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
public JWTFilter(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//cookie들을 불러온 뒤 Authorization Key에 담긴 쿠키를 찾음
String authorization = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
System.out.println(cookie.getName());
if (cookie.getName().equals("Authorization")) {
authorization = cookie.getValue();
}
}
//Authorization 헤더 검증
if (authorization == null) {
System.out.println("token null");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
//토큰
String token = authorization;
//토큰 소멸 시간 검증
if (jwtUtil.isExpired(token)) {
System.out.println("token expired");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
//토큰 유효검증
if (!jwtUtil.validateToken(token)) { // 예: validateToken은 서명을 확인하는 메서드
System.out.println("Token validation failed");
filterChain.doFilter(request, response);
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
//userDTO를 생성하여 값 set
UserDTO userDTO = new UserDTO();
userDTO.setUsername(username);
userDTO.setRole(role);
//UserDetails에 회원 정보 객체 담기
CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDTO);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
1) 따라서 우리가 해야될건 UserDetailsService에서 우리의 DB에서 끌고 와 UserDetails 객체를 만든후 이것을 Provider에게 리턴하면 자동으로 Provider에서 password 기반으로 인증된 사용자인지 판단한다.
2) JWT Authentication Filter 및 JWT AuthorizationFilter를 구현하고 등록하면 된다. JWT AuthorizationFilter는 UsernamePasswordAuthenticationFilter를 상속받고 AbstractAuthenticationProcessingFilter이 UsernamePasswordAuthentication.attempAuthentication으로 인증 시도할때 JWT기반으로 인증할 수 있게 구현해주면 된다. 또한 JWT Authentication Filter로는 매 요청이 올때마다 이 필터에서 유효한 JWT 토큰인지 확인한다
유효하면 JWT 토큰에 녹아있는 유저의 이름, Role 정보를 이용하여 Authentication 객체를 만들어 직접 SecurityContextHolder를 이용하여 Context에 인증된 Authentication을 등록시키면 된다.
그 후 다음 필터로 넘겨주면 UsernamePasswordAuthenticationFilter( AbstractAuthenticationProcessingFilter )가 동작하고 여기서 지금 현태 요청에 대한 Security Context의 Authentication 객체가 있는지 확인한다. 있으면 바로 다음 Filter로 , 없으면 이제 UsernamePasswordAuthenticationFilter의 attemptAuthentication 메서드를 수행해서 필터에서 직접 인증하겠다는 거다.
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureException;
public class JWTUtil {
private final String secretKey = "yourSecretKey"; // 이 값은 실제로 환경 변수로 관리하는 것이 좋습니다.
// 토큰 만료 여부 확인 메서드
public boolean isExpired(String token) {
Claims claims = getClaims(token);
return claims.getExpiration().before(new Date());
}
// 토큰에서 username 추출 메서드
public String getUsername(String token) {
Claims claims = getClaims(token);
return claims.getSubject();
}
// 토큰에서 role 추출 메서드
public String getRole(String token) {
Claims claims = getClaims(token);
return (String) claims.get("role");
}
// 서명 검증 메서드 (추가된 부분)
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token); // 이 메서드가 서명 검증을 수행합니다.
return true; // 서명이 유효하면 true 반환
} catch (SignatureException ex) {
// 서명이 유효하지 않으면 예외 발생
return false;
}
}
// JWT 토큰에서 Claims 추출하는 메서드
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
}
이러면 이제 JwtFilter쪽에서 토큰이 있는경우 검증을 해서 강제로 Context에 Authentication을 등록했으니까 다음 필터에서는 직접 검증로직이 돌아가지 않고 넘어가게 된다.
각종 인증관련한 클래스 선택
OAuth나 일반 로그인 인증과 같이 여러가지 인증 방법이 있다.
우리가 생각해야될건 Filter와 Authentication, Provider 그리고 UserDetails의 구현체들 이다.
일반 로그인에서의 Filter는 UsernamePasswordAuthenticationFilter 이고 Provider는 DaoAuthenticationProvider , User 이고
OAuth에서의 Filter는 OAuth2LoginAuthEnticationFilter, Provider는 OAuth2LoginAuthenticationProvider , OAuth2User 이다.
우선 Provider 먼저 보면 Provider의 가장 큰 역할은 UserDetails와 인증 안된 Authentication 객체를 서로 비교해야된다. 이걸 이제 비교하고 검증이 된 Filter로 넘겨야된다. 또한 일반 로그인 같은 경우는 그냥 앱 내에서 해결하면 되지만 OAuth같은 경우는 리소스 서버까지 다녀와야해서 구현이 달라질수밖에 없다. 또한 UserDetails도 인증방법이 달라지면 UserDetails의 인증 필드값도 달라지기 마련이고 이러다보면 Service쪽 구현체도 달라지게 된다.
또한 Filter도 마찬가지로 Authentication 구현체를 각 인증방법마다 달리 써야되다보니 이러한 Filter도 달라지게 되므로
Filter , Provider, UserDetails, Service 모두 인증 방법에 따라 구현체들이 달라지게 된다.
따라서 기본적으로 각각의 인증방법들에 대해 기본 구현체들이 어떻게 동작하는지 배우고 , 그때그때 필요한 놈들만 상속받아 구현함으로서 유연하게 설계가 가능하다.
출처: