JWT 인증방식을 활용해 소셜로그인, 일반로그인을 구현하기로 하였다.
필자는 이 중 일반로그인 구현을 담당하게 되었다.
1. JWT 인증 방식?
Json Web Token 인증 방식은 기존 세션 로그인과는 다르게 서버에 부담이 없는 무상태성(stateless)이 특징이다.
클라이언트가 로그인정보를 관리하므로 보안적으로 취약할 수 있다는 단점이 있으나 서버에 부하가 적고 확장이 쉽다는 점에 장점이 있다. 또한 토큰이 탈취당하는 보안 취약성도 리프레시 토큰의 도입으로 부분 상쇄될 수 있어 자주 사용된다.
2. 구현 원리 & 방법
2-1. JWT 인증 과정
미리 설정한 특정 url(회원가입 등)을 제외하고는 클라이언트에서 서버에 API 요청을 보낼 때 항상 헤더에 엑세스 토큰을 담아 요청을 보내야 한다.
(형식: "Authorization [엑세스 토큰]")
토큰이 유효하면 요청이 처리될 것이고, 토큰이 유효하지 않으면 실패메세지가 반환될 것이다.
실패메세지가 반환되면 헤더에 리프레시토큰을 담아 엑세스토큰 재발급 API에 요청을 보내면 리프레시 토큰의 유효성에 따라 엑세스 토큰이 재발급 될 것이다.
(형식: "Authorization-refresh [리프레시 토큰]")
※ Spring Security의 경우 엑세스토큰 재발급 URL이 따로 없다. 어떤 URL로 요청을 보내도 필터체인에서 요청을 가로채서 리프레시 토큰이 유효하다면 엑세스 토큰을 재발급 할 것이다.
2-2 Spring에서의 적용 방법 및 원리; Spring Security Filter
Spring에서는 인증/인가를 Spring Security를 바탕으로 처리한다.
Spring Security에 JWT 인증방식을 적용하는 방법은 필터체인에 커스텀JWT필터를 삽입하는 것이 핵심이다.
Java Servlet Filter는 클라이언트의 요청과 서버의 응답을 가로채고 처리할 수 있는 기능을 제공한다.
Spring Security의 Filter는 이 Servlet Filter의 구현체로 HTTP 요청과 응답에 대해 인증, 인가, CSRF 보호, CORS 설정 등 다양한 보안기능을 수행한다.
Spring Security의 필터는 체인 형태의 필터체인으로 구성돼있으며 개발자가 커스텀하여 필터를 추가하거나 순서를 변경할 수 있따. HTTP 요청이 서버에 들어오면 해당 요청이 컨트롤러에 전달되기 전에 필터체인이 요청을 가로채 설정된 필터체인의 순서에 따라 각 필터가 순차적으로 실행된다. 이때 JWT 필터를 추가하였다면 JWT 인증 및 인가를 수행해 실패하면 컨트롤러로 요청을 보내지 않고 실패메세지를 클라이언트로 반환한다.
2-3. Spring Security FilterChain에 커스텀JWT필터 추가
JWT 인증방식의 로그인을 Spring에 적용하기 위해서는
Spring Security FilterChain에 아래 2가지 필터를 추가한다.
1. jwtAuthenticationProcessingFilter()
2. customJsonUsernamePasswordAuthenticationFilter()
두 필터 모두 "/login" 요청에는 작동하지 않게 설정돼있으며
1번 필터의 경우 요청에 대해 헤더의 엑세스토큰과 리프레시토큰을 추출하고 사용자가 정의한 재발급, 인증 확인, 인가 로직을 수행한다.
2번 필터의 경우 최초 로그인시 토큰 발급에 관한 로직(엑세스토큰, 리프레시토큰 생성 및 발급, 리프레시토큰 DB에 저장)을 수행한다.
3. 구현
3-1. jwtAuthenticationProcessingFilter
jwtTokenProvider(토큰 생성, 헤더에서 토큰 추출 등 토큰 로직)는 따로 구현해야함.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login"; // "/login"으로 들어오는 요청은 Filter 작동 X
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String accessToken= jwtTokenProvider.extractAccessToken(request) // header에서 refreshToken 추출
.filter(jwtTokenProvider::isTokenValid)
.orElse(null);
String refreshToken = jwtTokenProvider.extractRefreshToken(request) // header에서 refreshToken 추출
.filter(jwtTokenProvider::isTokenValid)
.orElse(null);
if (accessToken == null && refreshToken != null) { // accessToken 만료 -> refreshToken 존재
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return;
}
if (refreshToken == null) { // accessToken X: 403 에러 / accessToken O: 인증 성공
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
// refreshToken로 검색 후 accessToken 재발급 후 전송
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken) // refreshToken으로 유저 찾기
.ifPresent(user -> {
String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getId()); // accessToken 생성
log.info("New accessToken issued: " + newAccessToken); // 재발급된 accessToken 출력
jwtTokenProvider.sendAccessToken(response, newAccessToken); // accessToken 전송
// jwtTokenProvider.sendAccessToken(response, jwtTokenProvider.createAccessToken(user.getEmail(), user.getId())); // accessToken 생성 후 전송
});
}
// accessToken 확인 후 인증 확인
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
jwtTokenProvider.extractAccessToken(request)
.filter(jwtTokenProvider::isTokenValid)
.ifPresent(accessToken -> {
jwtTokenProvider.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)
);
jwtTokenProvider.extractUserId(accessToken)
.ifPresent(userId -> log.info("추출된 userId: {}", userId));
});
filterChain.doFilter(request, response);
}
// 인증 허가
public void saveAuthentication(User myUser) {
String password = myUser.getPassword();
if (password == null) {
password = generateRandomPassword(12); // 소셜 로그인 유저의 비밀번호 임의로 설정
}
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 비밀번호 랜덤 생성
private String generateRandomPassword(int length) {
final String CHAR_SET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()-_=+[]{}|;:,.<>?";
SecureRandom secureRandom = new SecureRandom();
StringBuilder password = new StringBuilder(length);
for (int i = 0; i < length; i++) {
int index = secureRandom.nextInt(CHAR_SET.length());
password.append(CHAR_SET.charAt(index));
}
return password.toString();
}
}
3-2. customJsonUsernamePasswordAuthenticationFilter
public class CustomJsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; // "/login"으로 오는 요청을 처리
private static final String HTTP_METHOD = "POST"; // 로그인 HTTP 메소드는 POST
private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리
private static final String USERNAME_KEY = "email"; // 회원 로그인 시 이메일 요청 JSON Key : "email"
private static final String PASSWORD_KEY = "password"; // 회원 로그인 시 비밀번호 요청 JSon Key : "password"
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); // "/login" + POST로 온 요청에 매칭된다.
private final ObjectMapper objectMapper;
public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
String email = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);//principal 과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
}
3-3. SecurityConfig
위에서 구현한 두 필터를 .addFilterAfter, .addFilterBefore를 통해 추가.
이 때 customJsonUsernamePasswordFilter는 LoginSuccessHandler와 LoginFailureHandler를 추가하여 시큐리티 필터체인에 추가.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final LoginService loginService;
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.disable()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll()
.requestMatchers("/oauth2/sign-up", "/sign-up", "/*/additional-info").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().authenticated()
)
.logout(logout -> logout
.logoutSuccessUrl("/logout-success"))
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2LoginSuccessHandler)
.failureHandler(oAuth2LoginFailureHandler)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
)
.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class)
.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonUsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder());
provider.setUserDetailsService(loginService);
return new ProviderManager(provider);
}
@Bean
public LoginSuccessHandler loginSuccessHandler() {
return new LoginSuccessHandler(jwtTokenProvider, userRepository);
}
@Bean
public LoginFailureHandler loginFailureHandler() {
return new LoginFailureHandler();
}
@Bean
public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordAuthenticationFilter() {
CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordLoginFilter
= new CustomJsonUsernamePasswordAuthenticationFilter(objectMapper);
customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessHandler());
customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return customJsonUsernamePasswordLoginFilter;
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationProcessingFilter() {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository);
return jwtAuthenticationFilter;
}
}
4. 테스트
4-1. 회원가입 테스트
UserSignUpDto에 맞게 json의 형식으로 key, value를 넣어
/sign-up에 POST요청을 하여
회원가입에 성공한 모습이다.
컨트롤러 계층에서 /sign-up의 POST요청을 UserSignUpDto에 담아 서비스계층으로 보내고
서비스계층에서 리포지토리계층으로 전달해
리포지토리 계층의 save함수를 통해 DB에 저장.
실제로 MySQL의 데이터베이스에 저장됐음을 확인할 수 있다.
4-2. 로그인 테스트
일반로그인 사용자 커스텀 필터에 정의한 형식대로 key, value를 넣어
/login에 POST요청을 하여
엑세스토큰과 리프레시 토큰이 발급된 즉, 로그인에 성공한 모습이다.
/login의 POST요청은 SecurityConfig.java에 정의된 SecurityFilterChain 메서드에 의해 컨트롤러로 요청이 보내지기 전에
일반로그인 사용자 커스텀 필터를 먼저 통과한다.
해당 커스텀 필터에는 이를 처리하는 로직이 들어있어 이 과정에서 엑세스 토큰과 리프레시 토큰을 새로 발급해 return한다.
즉 필터에서 요청을 처리, 반환해버려 컨트롤러 까지 요청이 도달하지 못한다.
실제로 MySQL에서도 User테이블의 refresh_token칼럼에 리프레시토큰이 저장됐음을 확인할 수 있다.
4-3. 접근 권한 테스트(토큰 없이)
엑세스토큰 없이 privileged 페이지에 접근하려 할 때는 Spring Security의 디폴트 설정에 의해 /login페이지로 리디렉트됨.
반면, 엑세스 토큰을 헤더에 넣고 privileged 페이지에 접근(/jwt-test에 GET요청)할 때는 올바르게 해당 페이지에 접근됨을 확인할 수 있음.
4-4. 접근 권한 테스트(기한 만료된 토큰)
기한이 만료된 엑세스 토큰을 헤더에 넣고 privileged URL(/jwt-test)에 GET 요청을 했더니
권한이 없어 Spring Security의 디폴트 설정에 의해 /login html페이지가 반환되는 모습이다.
4-5. 엑세스 토큰 재발급 확인
만료된 엑세스토큰과 만료되지 않은 리프레시 토큰을 헤더에 담아 privileged URL에 요청을 보냈더니 새 엑세스토큰이 발급됨.
이 때 구현의 차이인데 현 코드에서는 리프레시 토큰까지 재발급됨.
차후 수정이 필요해보임
ㄴ (11.20)엑세스토큰만 발급되게 수정 완료.
'웹개발 > Devkor-Ontime' 카테고리의 다른 글
[Devkor-Ontime][트러블슈팅] 유저계정삭제 API 호출시 외래키 제약조건으로 인한 오류 해결 (500 Internel 서버 에러) (0) | 2025.02.13 |
---|---|
[Devkor-Ontime] 친구추가 관련 기능 구현 (0) | 2025.01.28 |
[Devkor-Ontime] 성실도 관련 기능 구현 (0) | 2024.11.15 |
[Devkor-Ontime] 알림 스케줄러 구현 (0) | 2024.11.15 |