Spring Security와 JWT를 활용해 로그인을 구현해보고 과정을 정리해보려 한다.
로그인에서 인증
,인가
, JWT
에 대해서 더 알고 싶다면 아래 링크를 참조하길 바란다.
https://velog.io/@gwon477/Spring-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80
목표
스프링 시큐리티 6 프레임워크를 활용하여 JWT 기반의 인증/인가를 구현하고 회원 정보 저장(영속성) MySQL 데이터베이스를 활용한다.
구현
- 인증 : 로그인
- 인가 : JWT를 통한 경로별 접근 권한
먼저 Spring Security에 대한 이해가 필요하다.
아래 그림은 Spring Boot 서버로 요청이 들어왔을때 이뤄지는 프로세스이다. Reqest -> FilterChain -> Dispatcher Servlet -> Controller ->Service로 프로세스가 진행되고 Spring Security를 공부하는 입장에서는 Deispatcher Servlet 앞단에서 FilterChain이 가장 먼저 요청에 대한 처리를 한다는 것이다.
여기서 Spring Security는 그림처럼 서블릿 컨테이너(Tomcat)에 있는 체인 중 Delegating FilterProxy를 Filter ChainProxy로 등록해 모든 요청을 가로채 커스텀한 필터를 수행하도록 한다.
가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리디렉션, 서블릿으로 요청 전달을 진행한다.
이런 SecurityFilterChain의 순서는 아래와 같다.
핵심적인 Filter를 살펴보면,
SecurityContextPersistenceFilter
: SecurityContextRepository에서 SecurityContext(접근 주체와 인증에 대한 정보를 담고 있는 객체)를 가져오거나 저장하는 역할
LogoutFilter
: 설정된 로그아웃 URL로 오는 요청을 확인해 해당 사용자를 로그아웃 처리 함
UsernamePasswordAuthenticationFilter
: 인증 관리자. 폼 기반 로그인 시 사용되는 필터로 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임. 인증이 성공하면 - AuthenticationSuccessHandler, 실패하면AuthenticationFailureHandler를 실행
DefaultLoginPageGeneratingFilter
: 사용자가 로그인 페이지를 따로 지정하지 않았을 때 기본으로 설정하는 로그인 페이지 관련 필터
BasicAuthenticationFilter
: 요청 헤더에 있는 아이디와 패스워드를 파싱해 인증 요청 위임. 인증이 성공하면 AuthenticationSuccessHandler를, 인증에 실패하면 AuthenticationFailureHandler 실행
RequestCacheAwareFilter
: 로그인 성공 후, 관련 있는 캐시 요청이 있는지 확인하고 캐시 요청 처리.
SecurityContextHolderAwareRequestFilter
: HttpServletRequest 정보를 감싼다. 필터 체인 상의 다음 필터들에게 부가 정보를 제공함.
AnonymousAuthenticationFilter
: 필터가 호출되는 시점까지 인증되지 않았다면 익명 사용자 전용 객체인 AnonymousAuthentication을 만들어 SecurityContext에 넣어준다.
SessionManagementFilter
: 인증된 사용자와 관련된 세션 작업 진행. 세션 번조 방지 전략 설정, 유효하지 않은 세션에 대한 처리, 세션 생성 전략을 세우는 등의 작업 처리
ExceptionTranslationFilter
: 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달
FilterSecurityInterceptor
: 접근 결정 관리자. AccessDecisionManager로 권한 부여 처리를 위임함으로써 접근 제어 결정을 쉽게 해준다. 이 과정에서 이미 사용자 인증이 되어있으므로 유효한 사용자인지도 알 수 있음. 인가 관련 설정 가능.
Form 로그인 방식에서 UsernamePasswordAuthenticationFilter
Form 로그인 방식에서는 클라이언트단이 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthentication 필터에서 회원 검증을 진행을 시작한다.
(회원 검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받음)
JWT 프로젝트는 SecurityConfig에서 formLogin 방식을 disable 했기 때문에 기본적으로 활성화 되어 있는 해당 필터는 동작하지 않는다.
따라서 로그인을 진행하기 위해서 필터를 커스텀하여 등록해야 한다.
로그인 로직 구현 목표
- 아이디, 비밀번호 검증을 위한 커스텀 필터 작성
- DB에 저장되어 있는 회원 정보를 기반으로 검증할 로직 작성
- 로그인 성공시 JWT를 반환할 success 핸들러 생성
- 커스텀 필터 SecurityConfig에 등록
📌 LoginFilter : 로그인 검증을 위한 커스텀 UsernamePasswordAuthentication 필터 작성
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html
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) {
}
}
📌 Json으로 요청을 하는경우
LoginDto 클래스를 생성해서 아래 내용을 LoginFilter에 추가해준다.
LoginDTO loginDTO = new LoginDTO();
try {
ObjectMapper objectMapper = new ObjectMapper();
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
loginDTO = objectMapper.readValue(messageBody, LoginDTO.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
📌 Security Config : 커스텀 로그인 필터 등록
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((auth) -> auth.disable());
http
.formLogin((auth) -> auth.disable());
http
.httpBasic((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.anyRequest().authenticated());
//필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요
http
.addFilterAt(new LoginFilter(), UsernamePasswordAuthenticationFilter.class);
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
📌 UserRepository
public interface UserRepository extends JpaRepository<UserEntity, Integer> {
Boolean existsByUsername(String username);
//username을 받아 DB 테이블에서 회원을 조회하는 메소드 작성
UserEntity findByUsername(String username);
}
📌 CustomUserDetailsService : UserDetailsService를 커스텀
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details-service.html
@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;
}
}
📌 CustomUserDetails : UserDetails 커스텀 구현
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details.html
public class CustomUserDetails implements UserDetails {
private final UserEntity userEntity;
public CustomUserDetails(UserEntity userEntity) {
this.userEntity = userEntity;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userEntity.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
@Override
public String getUsername() {
return userEntity.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
이제 Authentication Manager에서 회원 검증이 성공/실패에 대한 메서드를 LoginFilter에서 처리를 해준다. 성공일 경우 JWT 토큰을 발급해 응답으로 전달하고, 실패인 경우 인증 정보가 잘못되었다는 응답을 준다.
📌 application.properties
spring.jwt.secret=vmfhaltmskdlstkfkdgodyroqkfwkdbalroqkfwkdbalaaaaaaaaaaaaaaaabbbbb
📌 JWTUtil : 0.12.3
@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());
}
// 토큰을 발급한다. accesstoken, refreshtoken을 발급하는 내용은 용도에 맞게 설정한다.
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();
}
}
📌 LoginFilter : JWTUtil 주입
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
// authenticationManager에서 인증이 성공하면 해당 메서드 실행
// JWT 토큰을 응답으로 전달
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
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);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
📌 SecurityConfig에서 Filter에 JWTUtil 주입
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
//JWTUtil 주입
private final JWTUtil jwtUtil;
public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) {
this.authenticationConfiguration = authenticationConfiguration;
this.jwtUtil = jwtUtil;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((auth) -> auth.disable());
http
.formLogin((auth) -> auth.disable());
http
.httpBasic((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.anyRequest().authenticated());
//AuthenticationManager()와 JWTUtil 인수 전달
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
여기까지가 Login 과정이였고 이제부터는 서비스 사용에서 토큰을 보내면 내용을 검증해서 인가 하는 과정을 알아보겠다.
JWT 검증 필터
스프링 시큐리티 filter chain에 요청에 담긴 JWT를 검증하기 위한 커스텀 필터를 등록해야 한다.
해당 필터를 통해 요청 헤더 Authorization 키에 JWT가 존재하는 경우 JWT를 검증하고 강제로SecurityContextHolder에 세션을 생성한다. (이 세션은 STATLESS 상태로 관리되기 때문에 해당 요청이 끝나면 소멸 된다.)
📌 SecurityConfig에서 Filter에 JWTUtil 주입
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 authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
📌 SecurityConfig JWTFilter 등록
@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 {
http
.csrf((auth) -> auth.disable());
http
.formLogin((auth) -> auth.disable());
http
.httpBasic((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.anyRequest().authenticated());
//JWTFilter 등록
http
.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
JWT Filter에서 토큰을 검증할 때 일시적으로 세션을 생성해 ContextHolder에 저장하기 때문에 이 점을 이용한다면 토큰 정보를 추출해 사용할 수 있다.
SecurityContextHolder.getContext().getAuthentication().getName();
예시)
@Controller
@ResponseBody
public class MainController {
@GetMapping("/")
public String mainP() {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
return "Main Controller : "+name;
}
}
https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html
웹 브라우져에서는
교차출처 리소스 공유
를 막고있다. 하지만 프론트 서버와 백엔드 서버의 포트번호가 다르게 설정되어있기 때문에 CORS설정을 해줘서 이를 해결해야한다.
설정은 Security Config
,CorsMvcConfig
두 곳에 필요하다. Controller에 접근하는 API는 CorsMvcConfig로 해결이 가능하지만 반드시 Security Filter를 거치는 Login API 같은 경우 Security Config설정을 해줘야한다.
📌 Security Config
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
})));
return http.build();
}
📌 CorsMvcConfig
@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry corsRegistry) {
corsRegistry.addMapping("/**")
.allowedOrigins("http://localhost:3000");
}
}
https://tech-monster.tistory.com/95
https://velog.io/@jinyoungchoi95/JWTJson-Web-Token-%EC%9D%B8%EC%A6%9D%EB%B0%A9%EC%8B%9D
https://www.youtube.com/watch?v=3Ff7UHGG3t8&t=507s
저랑 같은 영상 보신듯한데 출처는 남겨주시는게 좋지 않을까요?