기존 방법 - WEBPermalink
이번에 주로 다룰 부분은 OAuth 백엔드를 구현할때 App과 Web을 위한
- OAuth 요청 방법의 차이
- Refresh token의 처리 차이
- 구현해야할 부분의 차이
특히 2번에서 전부터 고민이 많았는데, 이번 계기로 정리가 조금 되었다.
Copy code @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
// cors
.cors(cors -> cors.configurationSource(apiCorsConfigurationSource()))
// 경로별 인가
.authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST).permitAll()
.anyRequest().authenticated()
)
// oauth
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo.userService(customOauth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
)
// 필터 추가
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, jwtAuthFilter.getClass());
return http.build();
}
oauth 부분 설정한 것 처럼, 기본 엔드포인트인
/oauth2/authorization/{registrationId}로 요청을 받으면
registrationId에 맞는 로그인 화면으로 redirect 시켜준다.
요청Permalink
그림으로 보면 “로그인 버튼 클릭” 부분이다.
엔드포인트 개발이 필요 없다.
구현해야할 부분 - 개념Permalink
Spring security에서 모든걸 제공해 주기 때문에, 동그라미 친 부분만 개발하면 된다.
- resource server에서 제공받은 사용자 get or save : CustomOauth2UserService
- 리소스 제공 : success handler / failure handler
- JWT 생성 : JWT Provider
- JWT 인가 : JWT Filter
구현해야할 부분 - 코드Permalink
- CustomOauth2UserService
Copy code @Service @RequiredArgsConstructor @Slf4j public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { Map<String, Object> attributes = super.loadUser(userRequest).getAttributes(); String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.of(registrationId, attributes); UserEntity userEntity = getOrSave(oAuth2UserInfo); return new PrincipalUserDetails(userEntity, attributes); } private UserEntity getOrSave(OAuth2UserInfo oAuth2UserInfo) { UserEntity userEntity = userRepository.findByEmail(oAuth2UserInfo.getEmail()) .orElseGet(oAuth2UserInfo::toEntity); return userRepository.save(userEntity); } }
- success handler / failure handler
Copy code@Component @RequiredArgsConstructor @Slf4j public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtProvider jwtProvider; @Value("${etc.front-auth-success}") private String authSuccessUrl; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication){ PrincipalUserDetails principal = (PrincipalUserDetails) authentication.getPrincipal(); String userId = principal.getUserEntity().getUserId(); String access = jwtProvider.generateAccessToken(authentication, userId); // TODO: 웹 사용시 경로 수정, 헤더->쿼리 파라미터로 수정 response.setHeader("access", access); // response.sendRedirect(authSuccessUrl); } } @Component @Slf4j public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { log.error("OAuth2FailureHandler onAuthenticationFailure"); response.sendError(SC_BAD_REQUEST, "소셜 로그인에 실패했습니다."); } }
- JWT : provider
Copy code@Component @Slf4j @RequiredArgsConstructor public class JwtProvider { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration.access}") private Long ACCESS_TOKEN_EXPIRE_TIME; @Value("${jwt.expiration.refresh}") private Long REFRESH_TOKEN_EXPIRE_TIME; private SecretKey secretKey; private final RefreshTokenRedisService refreshTokenRedisService; @PostConstruct protected void initSecretKey() { this.secretKey = new SecretKeySpec(secret.getBytes(UTF_8), Jwts.SIG.HS512.key().build().getAlgorithm()); } public String generateAccessToken(Authentication authentication, String userId) { String refreshToken = generateRefreshToken(authentication, userId); refreshTokenRedisService.saveRefreshToken(userId, refreshToken, REFRESH_TOKEN_EXPIRE_TIME); return generateToken(authentication, ACCESS_TOKEN_EXPIRE_TIME, "access", userId); } private String generateRefreshToken(Authentication authentication, String userId) { return generateToken(authentication, REFRESH_TOKEN_EXPIRE_TIME, "refresh", userId); } private String generateToken(Authentication authentication, Long expirationMs, String category, String userId) { String authorities = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining()); return Jwts.builder() .subject(userId) .claim("category", category) .claim("authorities", authorities) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expirationMs)) .signWith(secretKey) .compact(); } // 만료되었을때만 false 반환 public Boolean validateToken(String token) { try { Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token); return true; } catch (ExpiredJwtException e) { return false; } catch (MalformedJwtException e) { throw new TokenException(INVALID_TOKEN); } catch (SecurityException e) { throw new TokenException(INVALID_SIGNATURE); } catch (Exception e) { throw new AuthException(INVALID_TOKEN); } } public Authentication getAuthentication(String token) { Claims claims = parseClaims(token); List<GrantedAuthority> authorities = Stream.of(claims.get("authorities", String.class).split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); // security User User principal = new User(claims.getSubject(), "", authorities); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } public String reissueWithRefresh(String refreshToken) { Authentication authentication = getAuthentication(refreshToken); String userId = getSubject(refreshToken); return generateAccessToken(authentication, userId); } private Claims parseClaims(String token) { try { return Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token) .getPayload(); } catch (ExpiredJwtException e) { return e.getClaims(); } } // Subject - userId : userId 반환 public String getSubject(String token) { return parseClaims(token).getSubject(); } }
- Jwt : filter
이부분이 refresh 토큰과 관련이 있어서 다음 부분으로
Refresh TokenPermalink
Refresh 토큰을 처리하는 방법이 사람들마다 아주 다양하다.
정리하면
- access, refresh 모두 클라이언트로 전송 (Stateless에 가장 부합)
여기서도 쿠키, 헤더, body 보내는 다양한 방법이 있지만 헤더로 사용.
나는 JWT 특징인 stateless를 살리기 위해 이 방법을 사용했었다.
하지만 만료 기간을 길게 설정하는 refresh 토큰이 탈취 당했을때 문제가 생긴다는 단점이 있다.
그럼에도 불구하고 로그인이 편리한 web 특성을 생각해 refresh 토큰 만료도 하루정도로 짧게 가져가고 이 방법을 사용했다. - access 보내고, refresh 저장 (Session 인증과 비슷)
stateless 특성을 조금이라도 유지하기 위해 Redis를 많이 사용하지만
난 그럴거면 세션인증 방식을 사용하는게 낫다고 생각해 사용하지 않았다.
현재 버전 - APPPermalink
app에서는 몇가지 문제점이 발생한다.
요청Permalink
요청과정이 조금 다르다
이전 사진과 비교해보면
쉽게 말해 로그인 버튼을 클릭하면 우리 서버가 아니라,
리소스 서버로 바로 요청을 보내니까
Spring security 제공 엔드포인트가 필요 없다.
이유?
- app은 redirect를 할 수 없다.
- 브라우저로 나가는게 아니라, 인앱에서 처리하기 때문
구현해야할 부분 - 개념Permalink
파란색 부분으로 표시해 놓은곳을 개발하면 된다.
편의상 리소스서버는 google이라고 하겠다.
- 사용자에게 (google의) access token을 받는 엔드포인트 : User Controller Request
- (google의) access token으로 사용자 정보를 받아오는 서비스, get or save : OAuth Client (직접 구현)
https://www.googleapis.com/userinfo/v2/me 엔드포인트로 직접 요청을 보내야 한다.
난 RestTemplate을 Bean으로 등록해 사용했다. - 리소스 제공 : User Controller Response
- JWT 생성 : JWT Provider
- JWT 인가 : JWT Filter
JWT는 그대로인데, Spring Security가 제공해주던 부분을 일부 직접 해야한다.
나의 경우 refresh 토큰 로직을 수정해서 JWT Filter 부분 수정
자동 로그인 관련한 부분 때문인데, 뒤에서 다루어 보겠다
구현해야할 부분 - 코드Permalink
- User Controller Request
Copy code @PostMapping("/login/google") public ResponseEntity<ResponseUser> googleLogin (@RequestBody RequestToken requestToken) { // google(resource server)로 요청 // yumst db에 존재하면 반환, 없으면 추가 UserDto user = oAuthClient.loadUserByAccess(requestToken.getAccessToken()); // yumst server jwt 발급 String accessToken = getAccessToken(user); ResponseUser responseUser = modelMapper.map(user, ResponseUser.class); return ResponseEntity.status(OK) .header("access", accessToken) .body(responseUser); }
- OAuth Client
Copy code@Service @RequiredArgsConstructor public class OAuthClient { private final RestTemplate restTemplate; @Value("${etc.google-profile-url}") private String profileUrl; private final UserRepository userRepository; private final ModelMapper modelMapper; public UserDto loadUserByAccess(String accessToken) { ResponseGoogleAccess body = requestToGoogle(accessToken); OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.builder() .name(body.getName()) .email(body.getEmail()) .imageUrl(body.getPicture()) .build(); UserEntity userEntity = getOrSave(oAuth2UserInfo); return modelMapper.map(userEntity, UserDto.class); } public UsernamePasswordAuthenticationToken getAuthentication(String name) { List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); User principal = new User(name, "", authorities); return new UsernamePasswordAuthenticationToken(principal, "", authorities); } private ResponseGoogleAccess requestToGoogle(String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); HttpEntity<RequestGoogleAccess> httpEntity = new HttpEntity<>(headers); return restTemplate.exchange(profileUrl, HttpMethod.GET, httpEntity, ResponseGoogleAccess.class) .getBody(); } private UserEntity getOrSave(OAuth2UserInfo oAuth2UserInfo) { UserEntity userEntity = userRepository.findByEmail(oAuth2UserInfo.getEmail()) .orElseGet(oAuth2UserInfo::toEntity); return userRepository.save(userEntity); } }
- User Controller Response
위에 작성한 엔드포인트와 동일 - JWT Provider
그대로 - JWT Filter
Copy code@Component @Slf4j @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; private final RefreshTokenRedisService refreshTokenRedisService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String accessToken = request.getHeader("access"); if (accessToken == null || accessToken.isEmpty()) { filterChain.doFilter(request, response); return; } log.debug("Access token from request: {}", accessToken); validate(response, accessToken); setAuthentication(accessToken); filterChain.doFilter(request, response); } private void validate(HttpServletResponse response, String accessToken) { // access 만료 if (!jwtProvider.validateToken(accessToken)) { String userId = jwtProvider.getSubject(accessToken); Optional<RefreshToken> optionalRefresh = refreshTokenRedisService.findRefreshToken(userId); // refresh 만료 if (optionalRefresh.isEmpty()) { log.debug("Refresh token is not found. Redirect to login page."); throw new TokenException(REFRESH_EXPIRED); } // refresh redis에 존재하고 유효 String refreshToken = optionalRefresh.get().getRefreshToken(); if (jwtProvider.validateToken(refreshToken)) { log.debug("Access token is expired. Trying to reissue with refresh token."); // 재발급 String newAccessToken = jwtProvider.reissueWithRefresh(refreshToken); response.setHeader("access", newAccessToken); } } } private void setAuthentication(String accessToken) { Authentication authentication = jwtProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); } }
Refresh TokenPermalink
백엔드에서 처리
App을 사용할때 하루에 한번씩 로그인을 시키는 앱은 없다.
그래서 refresh token 만료를 짧게 가져갈 수가 없었다.
결국 Redis를 사용해 refresh token을 저장하는 방법밖에 없다고 판단했다.
이러면 재발급 로직도 Auth JWT Filter에 추가해야 하는데,
기존처럼 클라이언트가 보낸 refresh token을 사용하는게 아니라
Redis에 userId를 키로 잡은 refresh token을 저장하고
ttl을 설정해준다.
그리고 재발급시 인증 정보가 있는지 확인하고
있다면 header에 포함시켜 응답
access token 만료는 15분으로 설정
프론트엔드에서 처리 - Flutter DIO
access 만료되어서 401응답이 오면 refresh 요청을 보내는 사람도 있지만
나는 백엔드에서 세션에 있다면 자동으로 재발급 시켜주는게 편할거라고 생각했다.
그렇기 때문에 백엔드 응답 헤더에 access token이 포함되어 있으면
자동으로 갱신하는 로직이 필요했는데
이걸 Flutter DIO를 사용해 처리했다.
이번 글은 백엔드 내용이니 생략
소감Permalink
app 동작 방식이 이렇게 다르다는걸 알았다면
Web 버전 OAuth 개발하는 시간을 아꼈을 것 같다…
다음엔 좀더 빠르게 할 수 있을듯
- 프론트엔드를 직접 해보니, 백엔드에서 어떻게 해야 할지 이해도가 조금 더 생긴 것 같다.
역시 직접 해보는게 최고다.
'PROJECT' 카테고리의 다른 글
[졸프] 출시, BM, 개선사항 (1) | 2025.06.06 |
---|---|
[졸프] 3주만에 플러터로 앱 만들기! (0) | 2025.06.06 |
[졸프] Spring batch, 크롤링 트러블슈팅 (0) | 2025.06.06 |
[졸프] 데이터 수집: 네이버 지도 크롤링 + 공공데이터 - Spring Batch 활용 (0) | 2025.06.06 |
[졸프] 협업의 어려움과 방향성 (2) | 2025.06.06 |