스프링 시큐리티 6 프레임워크를 활용한 jwt 기반의 인증/인가를 구현하고 회원 정보 저장(영속성) MySQL 데이터베이스를 활용한다. 스프링 시큐리티 JWT글을 학습하고 자료들을 활용하여 나에게 맞춰 정리한 내용이다.
구현 목표
내부 회원 가입 로직은 세션 방식과 JWT 방식의 차이가 없다.
1) 클라이언트가 POST 시, 서버의 Controller 에서 회원가입 시 입력한 정보를 받아온다.
2) Controller 에서 회원가입을 위한 Service 의 메서드를 호출한다.
3) 호출된 Service 의 메서드는 회원가입을 위한 Repository 의 메서드를 호출한다.
*각각의 메서드 호출시 DTO, 그리고 DB에 ORM 으로 매핑되는 UserEntity 를 사용한다.
로그인 요청을 받은 후, 세션 방식은 서버 세션이 유저 정보를 저장하지만 JWT 방식은 토큰을 생성하여 응답한다.
스프링 시큐리티는 클라이언트의 요청이 서블릿 컨테이너 내부의 여러개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후, 검증(인증/인가)을 진행한다.
DelegatingFilterProxy
서블릿 컨테이너(톰캣)에 존재하는 필터 체인에 DelegatingFilterProxy를 등록한 뒤 모든 요청을 가로챈다.
DelegatingFilterProxy 는 스프링 시큐리티가 표준 서블릿 컨테이너 매커니즘을 통해 서블릿 컨테이너에 등록한 필터이다. DelegatingFilterProxy는 요청이 들어올때마다 스프링 시큐리티의 FilterChainProxy 에게 처리를 위임한다. FilterChainProxy는 springSecurityFilterChain 이라는 이름의 빈으로 등록되어 있고, springSecurityFilterChain 빈 내부에는 여러 개의 보안 필터들이 등록되어 있다.
서블릿 필터 체인의 DelegatingFilter -> SecurityFilterChain (내부 처리 후) -> 서블릿 필터 체인의 DelegatingFilter
가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리디렉션, 서블릿으로 요청 전달을 진행한다.
SecurityFilterChain의 필터목록과 순서
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
...
http
.formLogin((auth) -> auth.loginPage("/login")
.loginProcessingUrl("/loginProc")
.permitAll()
);
...
return http.build();
}
}
Form 로그인 방식의 경우, 클라이언트단이 username 과 password를 전송하면 Security 필터를 통과하는데 이때 UsernamePasswordAuthentication 필터에서 회원 검증을 진행한다. (formLogin의 경우 UsernamePasswordAuthentication 필터가 자동으로 활성화된다)
회원 검증의 시, UsernamePasswordAuthentication 필터가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받는다. 이후 받은 정보를 사용자가 입력한 정보와 비교하여 로그인을 검증한다.
일반적으로 스프링 시큐리티의 formLogin 방식은 서버사이드 렌더링(SSR)을 사용한다. 로그인 폼을 서버에서 생성하고 서버에서 처리하는 방식이기 때문에 전체 HTML 문서가 서버에서 렌더링되어 클라이언트에게 전송된다.
만약 아무런 추가적인 설정을 하지 않고 formLogin()만 사용한다면, 스프링 시큐리티는 자동으로 기본 로그인 폼을 생성하여 사용자에게 제공한다.
반면에 JWT 프로젝트의 경우, 주로 클라이언트사이드 렌더링(CSR)을 사용하는 어플리케이션에서 많이 적용되기 때문에 로그인 페이지가 클라이언트에서 동적으로 생성된다. 따라서 formLogin 방식을 disable 하고 사용자 지정 필터를 구현하여 등록해야 한다. 아래는 LoginFilter라는 사용자 지정 필터를 구현한것이다. LoginFilter는 formLogin을 처리하는 UsernamePasswordAuthentication 필터를 대신하기 때문에 해당 필터의 위치에 LoginFilter를 추가해주어야 한다. UsernamePasswordAuthentication필터를 상속하면, 기본적으로 해당 필터의 정의에 따라 '/login' URL에 대한 'POST' 요청을 처리하게 된다. 따라서 따로 Controller에서 URL 설정할 필요가 없다.
LoginFilter
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public LoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//클라이언트 요청에서 username, password 추출
String username = obtainUsername(request);
String password = obtainPassword(request);
//스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
//token에 담은 검증을 위한 AuthenticationManager로 전달
return authenticationManager.authenticate(authToken);
}
//로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
}
//로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
}
}
UsernamePasswordAuthentication필터를 상속받아 override 하는 attemptAuthentication 메서드는 클라이언트 요청에 포함된 username 과 password를 추출한다. 이후 UsernamePasswordAuthenticationToken 객체에 담아서 AuthenticationManager로 전달한다.
UsernamePasswordAuthenticationToken 클래스의 객체는 두번 생성되는데 다음과 같은 경우이다.
AuthenticationManager 는 @Service 빈으로 등록된 UserDetailsService 빈을 이용하는데, AuthenticationManager 가 인자로 받은 authToken 내부의 username 을 이용해 UserDetailsService 는 해당하는 유저를 찾고, 해당 유저 정보를 UserDetails에 담아 return 한다.
CustomUserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//DB에서 조회
UserEntity userData = userRepository.findByUsername(username);
if (userData != null) {
//UserDetails에 담아서 return하면 AutneticationManager가 검증 함
return new CustomUserDetails(userData);
}
return null;
}
}
AuthenticationManager는 UserDetailsService로부터 return 받은 해당 유저 정보와 authToken의 유저 정보(username, password)를 비교하여 로그인을 검증한다.
*AuthenticationManager는 인터페이스로 등록된 UserDetailsService의 구현부를 찾아서 내부의 loadByUsername 메서드를 실행한다. 따라서 단 하나의 구현부에만 @Service 어노테이션을 선언하여 작업해야 한다.
로그인 검증이 완료되면 로그인 성공/실패에 따라 successfulAutehntication/unsuccessfulAuthentication 함수가 호출된다.
로그인 성공시
*한줄로 정리
로그인 정보를 이용해 db에서 찾은 사용자 정보를 담은 authentication 객체를 사용해서 jwt 를 생성하게 된다. 즉 db에서 조회한 유저 정보를 통해 jwt 를 생성함. 따라서 권한의 경우도 db에 저장된 권한을 jwt 에 포함시키게됨
로그인 성공 시 successfulAuthentication 메서드가 호출된다. 이때 인자로 받는 Authentication 객체는 attemptAuthentication 메서드의 반환값으로, UserDetails 를 구현한 객체로부터 추출된 사용자 정보(UserEntity)를 담고있다. (유저의 정보, 검증을 위해 AuthenticationManager로 전달한 인자보다 role 등 더 많은 유저 정보를 가지고 있다)
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
...
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
//UserDetailsS
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
String token = jwtUtil.createJwt(username, role, 60*60*10L);
response.addHeader("Authorization", "Bearer " + token);
}
}
여기서 JWT 토큰을 생성하고 클라이언트에 반환하는 작업을 수행할 수 있다. JWT 토큰을 검증/생성을 위해 만들어 놓은 JWTUtil 빈을 이용하여 생성한다. 이후 생성된 JWT 토큰을 응답의 헤더에 포함시킨다.
*HTTP 인증 방식은 RFC 7235 정의에 따라 아래 인증 헤더 형태를 가져야 한다.
Authorization: 타입 인증토큰
//예시
Authorization: Bearer 인증토큰string
JWTUtil
@Component
public class JWTUtil {
private SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}")String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
로그인 실패시
로그인 실패 시 unsuccessfulAuthentication 메서드가 호출된다. 로그인 실패시 401(Unauthorized) 응답 코드를 반환하도록 하였다. 401 코드는 클라이언트가 인증되지 않았거나, 유효한 인증 정보가 부족하여 요청이 거부되었음을 의미하는 상태값이다.
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
...
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
//로그인 실패시 401 응답 코드 반환
response.setStatus(401);
}
}
JWTFilter 를 통해 요청의 헤더에서 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다. 이 세션은 STATELESS 상태로 관리되기 때문에 해당 요청이 끝나면 소멸된다.
JWTFilter
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 {
//request에서 Authorization 헤더를 찾음
String authorization= request.getHeader("Authorization");
//Authorization 헤더 검증
if (authorization == null || !authorization.startsWith("Bearer ")) {
System.out.println("token null");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
System.out.println("authorization now");
//Bearer 부분 제거 후 순수 토큰만 획득
String token = authorization.split(" ")[1];
//토큰 소멸 시간 검증
if (jwtUtil.isExpired(token)) {
System.out.println("token expired");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
//userEntity를 생성하여 값 set
UserEntity userEntity = new UserEntity();
userEntity.setUsername(username);
userEntity.setPassword("temppassword");
userEntity.setRole(role);
//UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
//토큰에서 추출한 사용자 정보를 담은 Authentication 객체 생성
Authentication authentication = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
JWT Filter 는 SpringFilterChain 에서 앞서 추가한 LoginFilter 의 앞에 위치시킨다. JWT 는 요청마다 매번 검증되어야 하기 때문에 OncePerRequestFIlter 를 상속받는다. OncePerRequestFilter는 모든 경로에 대해 request 요청시 동작한다. 이때 Override 되는 doFilterInternal 에서 요청의 Authorization 헤더에 포함된 토큰을 추출하여 검증을 진행한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil;
public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) {
this.authenticationConfiguration = authenticationConfiguration;
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
//JWTFilter 등록
http
.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
...
return http.build();
}
}
토큰을 검증할때는 서버가 토큰을 생성할 때 사용한 secret key를 이용해 토큰을 검증한다. 이후 토큰이 유효하다고 검증되면 스프링 시큐리티 인증 토큰을 생성하여 세션에 사용자로 등록한다.
유저 권한(role) 확인 방식
****JWTFilter 내 doFilterInternal 메서드****
// 토큰 유효성 검증 코드 생략
// 토큰이 유효할 경우 토큰 복호화해서 관련 유저 정보를 SecurityContext 에 저장
Long id = jwtProvider.getUserId(jwt);
String role = jwtProvider.getRole(jwt);
String nickname = jwtProvider.getNickname(jwt);
String email = jwtProvider.getEmail(jwt);
Users user = new Users(id, role, nickname, email, "");
//토큰에서 추출한 사용자 정보를 담은 Authentication 객체 생성
Authentication authentication = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authentication);
JWTFilter 에서 jwt의 유효성이 검증되면 해당 jwt 를 파싱해 사용자 정보를 담은 Authentication 객체를 생성해서 서버의 일시적인 세션에 저장한다.
이렇게 JWTFilter에서 일시적인 세션을 만들어 두면, 다음 순번으로 등장하는 등록해둔 인가 필터가 (SecurityConfgi requestMachers에서 경로별 설정에 따라 자동을 생성되는 필터) 세션에 담긴 해당 유저에 대한 권한 정보를 조회해서 해당 페이지로의 접근 여부를 결정한다. 따라서 JWT 프로젝트에서도 세션이 STATELESS 상태로 관리될 뿐이지 필수적으로 필요하다!
세션에 등록된 유저 정보
다음은 세션에 Authentication 객체로 등록되는 사용자의 정보(위의 authToken 변수)를 출력한 예시이다.
// System.out.println(authToken)
UsernamePasswordAuthenticationToken [
Principal=com.example.springsecurityjwt.Dto.CustomUserDetails@765b8176,
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[com.example.springsecurityjwt.Dto.CustomUserDetails$1@3403ad21]
]
이렇게 세션에 등록된 사용자 정보를 이용해 다양한 작업을 수행할 수 있다.
JWTFilter를 통과한 뒤 세션 확인
@Controller
@ResponseBody
public class MainController {
@GetMapping("/")
public String mainP() {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
return "Main Controller : "+name;
}
세션 내 현재 사용자 아이디
SecurityContextHolder.getContext().getAuthentication().getName();
세션 내 현재 사용자 role
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iter = authorities.iterator();
GrantedAuthority auth = iter.next();
String role = auth.getAuthority();
UsernamePasswordAuthenticationFilter 와 OncePerRequestFilter
requestMatchers('URL').permitAll()
permitAll()의 경우 해당 URL에 대해 거치는 필터들을 모두 통과시킨다는 뜻이다. 따라서 모든 경로에 대한 요청을 검증을 진행하는 JWTFilter의 경우, permiltAll()로 설정된 해당 경로를 통과시키게 된다. (지나치는게 아니라 검증하되 통과시키는 것임)
*LoginFilter의 경우 POST '/login' 경로만 검증하게 된다.
=> JWTFilter 는 모든 경로에서 거치게 되며, LoginFilter는 설정한 POST 로그인 경로만 거친다.
JWT를 발급받은 상태에서 /login 으로 username, password를 전송하는 경우
JWT를 발급받은 상태에서 /login 으로 username, password를 전송하는 경우 JWTFilter 와 LoginFilter 를 모두 거치게 된다. 따라서 JWT를 가진 유저가 /login 경로로 접근하려 하는 경우 개발자가 접근을 막는것이 좋다. (이미 로그인 된 상태이므로)
유저 권한(role) 확인 방식
위에서 설명했지만, 이렇게 JWTFilter에서 일시적인 세션을 만들어 두면, 다음 순번으로 등장하는 등록해둔 인가 필터가 (SecurityConfgi requestMachers에서 경로별 설정에 따라 자동을 생성되는 필터) 세션에서 해당 유저에 대한 role을 조회해서 접근 여부를 결정한다. 따라서 JWT 프로젝트에서도 세션이 STATELESS 상태로 관리될 뿐이지 필수적으로 필요하다.
SecurityContextHolder, SecurityContext, Authentication
세션에 저장되는 SecurityContext 는 Authentication 객체를 저장하며, SecurityContextHolder 를 통해 관리된다. 따라서 아래와 같은 구조를 가지고 있고 아래 코드와 같이 접근 가능하다.
SecurityContextHolder.getContext().getAuthentication().getName();
자료 출처
https://substantial-park-a17.notion.site/JWT-7a5cd1cf278a407fae9f35166da5ab03
https://crazy-horse.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-FilterChainProxy