해당 글은 진행 중인 프로젝트 Reev의 리포지토리에서 Discussion에 작성된 글을 가져온 것입니다.
http://서버도메인/api/v1/auth/oauth2/kakao
으로 요청.http://서버도메인/api/v1/auth/reissue
로 RT 재발급 요청http://서버도메인/api/v1/logout
요청 시 Cookie 안에 저장된 RT 삭제 (AT도 삭제되어야 함)http://서버주소/api/v1/auth/oauth2/kakao?redirect=/custom-page
로 redirect 파라미터를 붙여서 요청 시https://example.com/login/success?redirect=/custom-page
로 리다이렉트import React, { useState, useEffect } from "react";
import axios from "axios";
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [accessToken, setAccessToken] = useState(null); // Access token state 추가
useEffect(() => {
// URL에 있는 access_token을 파싱하여 로그인 처리
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("access_token");
if (token) {
setAccessToken(token); // Access token 저장
// access_token이 존재하면, 서버에 사용자 정보를 요청
}
}, []);
const reissueToken = () => {
axios
.get("http://localhost:8080/api/v1/auth/reissue", {
withCredentials: true, // 쿠키를 포함시켜 요청
})
.then((response) => {
if (response.status === 200) {
console.log("Headers: ", response.headers); // 전체 헤더를 출력해보기
let newAccessToken = response.headers["authorization"];
if (newAccessToken && newAccessToken.startsWith("Bearer ")) {
newAccessToken = newAccessToken.substring(7); // 'Bearer '를 제거
}
console.log("newAccessToken : ", newAccessToken);
setAccessToken(newAccessToken); // 새로 받은 액세스 토큰 저장
console.log("Token reissued successfully");
} else {
console.error("Failed to reissue token");
}
})
.catch((error) => {
console.error("Error during token reissue:", error);
});
};
const login = () => {
window.location.href =
"http://localhost:8080/api/v1/auth/oauth2/kakao";
};
const logout = () => {
axios
.post(
"http://localhost:8080/api/v1/logout",
{},
{ withCredentials: true }
)
.then((response) => {
if (response.status === 200) {
console.log("Logged out successfully");
setAccessToken(null); // 토큰도 초기화
} else {
console.error("Logout failed");
}
})
.catch((error) => {
console.error("Error during logout:", error);
});
};
return (
<div className="App">
<header className="App-header">
<h1>OAuth2 Login with Kakao</h1>
<button onClick={login}>Login with Kakao</button>
<button onClick={logout}>Logout</button>
<button onClick={reissueToken}>Reissue Token</button>
</header>
</div>
);
}
export default App;
요즘 웹사이트를 보면 다음과 같이 ##로 시작하기라는 로그인 옵션이 존재합니다. 이 기능을 통해 클릭 단 한번으로 로그인을 간편하게 할 수 있게 하는 것 뿐만 아니라, 연동되는 외부 웹 애플리케이션에서 Google 등이 제공하는 기능을 간편하게 사용할 수 있다는 장점이 있습니다.
OAuth2는 사용자가 자신의 자격증명을 서비스 제공자에게 직접 공유하지 않고도, 제3자 애플리케이션에 안전하게 접근 권한을 부여할 수 있는 인증 및 권한 부여 프로토콜로, 오늘날 웹과 모바일 애플리케이션에서 필수적인 기술로 자리 잡고 있습니다.
OAuth2의 주요 원리 중 하나는 권한 위임입니다. 사용자는 신뢰할 수 있는 인증 제공자(Google, Facebook 등)를 통해 본인의 신원을 확인하고, 제3자 애플리케이션이 자신의 데이터를 일정 범위 내에서 접근할 수 있도록 허가합니다. 이 과정에서 애플리케이션은 액세스 토큰을 받아 이를 통해 데이터에 접근하며, 사용자의 비밀번호나 민감한 정보를 직접 다루지 않기 때문에 보안성을 높입니다.
주요 구성 요소는 다음과 같습니다.
Resource Owner
Client
Authorization Server
Resource Server
Access Token
Refresh Token
위 구성 요소를 통해 사용자의 OAuth2 로그인 흐름과 API 호출까지의 과정을 그림으로 그려보면 다음과 같습니다.
그림 상으로 보면 복잡하지만 정말 간단히 요약하자면 다음과 같습니다.
1. 사용자가 로그인을 하여 Access Token을 발급 받는다.
7. Access Token을 검증 정보로 사용해 API를 요청하여 사용한다. (로그인을 해야만 사용 가능한 서비스)
build.gradle
// oauth2 dependency
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
Member.java
OAuth2User의 구현체입니다. 저희가 사용할 엔티티입니다.
@Entity
@Table(name = "Member")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member implements OAuth2User {
@Id
@Column(name = "user_id", length = 100, nullable = false, unique = true)
private String userId;
@Column(name = "nickname", nullable = false, length = 100)
private String nickname;
@Column(name = "profile_url", nullable = false, length = 1000)
private String profileUrl;
@Column(name = "role", nullable = false, length = 100)
private String role;
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getName() {
return this.nickname;
}
}
CustomOAuth2UserService.java
Member의 내용을 채우기 위해 Kakao로부터 온 JSON을 매핑하는 서비스입니다. Spring Security의 설정 파일인 SecurityConfig에서 OAuth2 서비스로 지정됩니다.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String oauth2ClientName = userRequest.getClientRegistration().getClientName();
if (!"kakao".equals(oauth2ClientName)) {
throw new OAuth2AuthenticationException("Unsupported provider: " + oauth2ClientName);
}
Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
String userId = attributes.get("id").toString();
String name = (String) profile.get("nickname");
String profileImage = (String) profile.get("profile_image_url");
Optional<Member> member = memberRepository.findByUserId(userId);
if (member.isPresent()) {
return member.get();
}
Member newMember = new Member(userId, name, profileImage, "ROLE_USER");
// 사용자 정보 저장
memberRepository.save(newMember);
return newMember;
}
}
AuthController.java
로그인, 로그아웃 외 RT 재발급, AT로 현재 로그인 상태 확인을 할 수 있습니다.
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
// todo : 헤더로 저장되는 AT 대신 RT로 할까?
@Operation(summary = "AT로 진행, Authorization 헤더로 검사함, Bearer AT 형식")
@GetMapping("/check")
public ResponseEntity<Map<String, Boolean>> checkAuth(@RequestHeader(value = "Authorization", required = false) String authorization) {
Map<String, Boolean> response = new ConcurrentHashMap<>();
boolean isAuthenticated = Strings.isNotBlank(authorization) &&
authorization.startsWith("Bearer ") &&
authService.checkAuth(authorization);
response.put("isAuthenticated", isAuthenticated);
return ResponseEntity.ok(response);
}
@Operation(summary = "RT로 진행, Redis 안의 RT와 쿠키의 RT를 재갱신함")
@GetMapping("/reissue")
public ResponseEntity<Void> reissueToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) {
if (Strings.isEmpty(refreshToken)) {
throw new UnauthorizedException();
}
ReissueResponseDto reissuedToken = authService.reissueToken(refreshToken);
ResponseCookie refreshCookie = CookieUtils.createCookie(
"refresh_token",
reissuedToken.getRefreshToken(),
60 * 60 * 24 * 7);
return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, reissuedToken.getAccessToken())
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.build();
}
}
RefreshTokenService.java
RefreshToken을 Redis에 저장/삭제하고 RT를 만드거나 그대로 사용하는 서비스입니다. Redis에 대해서는 나중에 서술합니다.
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final TokenProperties tokenProperties;
private final RedisRepository redisRepository;
private final JwtProvider jwtProvider;
public String findByUserId(String userId) {
return redisRepository.findByKey(userId);
}
public void saveRefreshToken(String userId, String refreshToken) {
Duration expiration = Duration.ofDays(tokenProperties.getRefreshTokenExpirationDay());
redisRepository.save(userId, refreshToken, expiration);
}
public void deleteRefreshToken(String userId) {
redisRepository.delete(userId);
}
public String getOrCreateRefreshToken(String userId) {
String existingToken = findByUserId(userId);
if (existingToken != null) {
return existingToken; // Redis에 저장된 토큰 재사용
}
String newToken = jwtProvider.createRefreshToken(userId);
saveRefreshToken(userId, newToken); // 새로 생성된 토큰 저장
return newToken;
}
}
JWT(JSON Web Token)는 JSON 기반의 토큰으로, 클라이언트와 서버 간에 정보를 안전하게 전달하기 위해 사용됩니다. 주로 인증과 권한 부여를 위해 활용되며, 세션을 대체할 수 있는 유용한 방법입니다.
JWT에는 userId
가 포함되며, 이 정보는 Access Token과 Refresh Token 역할을 합니다. 서버는 Redis에 userId:RefreshToken
형식으로 Refresh Token을 저장합니다. Refresh Token이 탈취되더라도 Redis의 값을 비교하여 현재 재발급된 Refresh Token과 다르다면 탈취 사실을 확인할 수 있습니다.
JwtProvider.java
JWT를 만들고 검증하는 곳입니다.
application.properties에서 값을 찾아와서 생성하고 검증하는 것을 볼 수 있습니다.
일단 AT, RT 둘 다 만료 기간은 3일로 잡았습니다.
Key는 application.properties를 통해서 만들어지고, 이를 이용해 암호화/복호화합니다.
@Component
public class JwtProvider {
private final Key key;
private final int accessTokenExpirationDay;
private final int refreshTokenExpirationDay;
public JwtProvider(ReevProperties reevProperties, TokenProperties tokenProperties) {
String jwtSecret = reevProperties.getJwtSecret();
this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
accessTokenExpirationDay = tokenProperties.getAccessTokenExpirationDay();
refreshTokenExpirationDay = tokenProperties.getRefreshTokenExpirationDay();
}
public String createAccessToken(String userId) {
Date expiredDate = Date.from(Instant.now().plus(accessTokenExpirationDay, ChronoUnit.DAYS));
return Jwts.builder()
.signWith(key, SignatureAlgorithm.HS256)
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(expiredDate)
.compact();
}
public String createRefreshToken(String userId) {
Date expiredDate = Date.from(Instant.now().plus(refreshTokenExpirationDay, ChronoUnit.DAYS));
return Jwts.builder()
.signWith(key, SignatureAlgorithm.HS256)
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(expiredDate)
.compact();
}
public String validateToken(String jwt) throws JwtException {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwt)
.getBody();
return claims.getSubject();
} catch (ExpiredJwtException e) {
throw new TokenExpiredException();
} catch (MalformedJwtException e) {
throw new MalformedTokenException();
}
}
}
Spring Security는 Spring Framework 기반 애플리케이션의 보안을 담당하는 모듈로, 인증(Authentication)과 권한 부여(Authorization) 기능을 제공합니다.
@AuthenticationPrincipal Long userId
를 통해서 불러옵니다.JwtAuthenticationFilter.java
OncePerRequestFilter의 구현체이고, 즉 커스텀 필터입니다. Spring Security에서 본 작업을 수행하기 전에 이 필터를 가져와서 JWT 내용을 JwtProvider로 추출/검증하고, 이후 SecurityContext에 사용자 정보(userId)를 저장합니다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final MemberRepository memberRepository;
/**
* 화이트 리스트에 포함된 요청은 필터링하지 않습니다.
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return Arrays.stream(SecurityEndpointPaths.WHITE_LIST)
.anyMatch(path ->
PatternMatchUtils.simpleMatch(path, request.getRequestURI()));
}
/**
* 요청에 대해 JWT 인증을 수행합니다.
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = parseBearerToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
String kakaoId = jwtProvider.validateToken(token);
if (Strings.isEmpty(kakaoId)) {
filterChain.doFilter(request, response);
return;
}
authenticateUser(kakaoId, request);
filterChain.doFilter(request, response);
}
/**
* Authorization 헤더에서 Bearer Token을 추출합니다.
*/
private String parseBearerToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (Strings.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
return null;
}
/**
* 사용자 인증을 수행합니다. (SecurityContext - 사용자 정보 저장)
*/
private void authenticateUser(String userId, HttpServletRequest request) {
Member member = memberRepository.findByUserId(userId)
.orElseThrow(MemberNotFoundException::new);
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(member.getRole()));
AbstractAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(member.getUserId(), null, authorities);
WebAuthenticationDetails authDetails = new WebAuthenticationDetailsSource().buildDetails(request);
authToken.setDetails(authDetails);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
OAuth2SuccessHandler.java
로그인이 성공하였을 경우 실행되는 핸들러입니다. Redis에 RT를 저장, 이후 프론트엔드에게 쿠키와 헤더를 전달하고 프론트엔드의 주소로 리다이렉트시킵니다.
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final ReevProperties reevProperties;
private final RefreshTokenService refreshTokenService;
private final TokenProperties tokenProperties;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Member oauth2User = (Member) authentication.getPrincipal();
String userId = oauth2User.getUserId();
String refreshToken = refreshTokenService.getOrCreateRefreshToken(userId);
ResponseCookie refreshCookie = createCookie(tokenProperties.getRefreshTokenName(), refreshToken);
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
String redirectUrl = getRedirectUrl(request);
response.sendRedirect(redirectUrl);
}
public String getRedirectUrl(HttpServletRequest request) {
String baseUrl = reevProperties.getFrontUrl().get(0) + "/login/success";
HttpSession session = request.getSession();
String redirectParam = (String) session.getAttribute(tokenProperties.getQueryParam());
session.removeAttribute(tokenProperties.getQueryParam());
return (redirectParam != null)
? baseUrl + "?redirect=" + redirectParam
: baseUrl;
}
private ResponseCookie createCookie(String name, String value) {
return ResponseCookie.from(name, value)
.secure(true)
.sameSite("None")
.httpOnly(true)
.path("/")
.maxAge(604800)
.build();
}
}
OAuth2FailureHandler.java
로그인이 실패할 경우 실행되는 핸들러입니다. 실패를 가리키는 주소로 리다이렉트시킵니다.
@Component
@RequiredArgsConstructor
public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final ReevProperties reevProperties;
private final TokenProperties tokenProperties;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
request.getSession().removeAttribute(tokenProperties.getQueryParam());
response.sendRedirect(reevProperties.getFrontUrl().get(0) + "/login/failure");
}
}
OAuth2LogoutHandler.java
로그아웃 시 실행되는 핸들러입니다. Redis에 RT를 삭제하고 이 때 쿠키도 같이 삭제합니다.
@Component
@RequiredArgsConstructor
public class OAuth2LogoutHandler implements LogoutHandler {
private final MemberRepository memberRepository;
private final RefreshTokenService refreshTokenService;
private final JwtProvider jwtProvider;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String refreshToken = extractRefreshToken(request.getCookies());
if (refreshToken == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
String userId = jwtProvider.validateToken(refreshToken);
if (!memberRepository.existsByUserId(userId)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
refreshTokenService.deleteRefreshToken(userId);
}
/**
* 쿠키에서 Refresh Token을 추출합니다.
*
* @param cookies 모든 쿠키
* @return Refresh Token
*/
public String extractRefreshToken(Cookie[] cookies) {
if (cookies == null) {
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> "refresh_token".equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
}
}
CustomAuthorizationRequestResolver.java
OAuth2 로그인 시 Authorization Request를 커스터마이징하는 역할을 수행합니다.
사용자가 /api/v1/auth/oauth2를 통해 OAuth2 로그인을 시도할 때, 추가적인 쿼리 파라미터(예: redirect)를 처리합니다.
redirect 쿼리 파라미터를 읽어 세션에 저장하며, 이후 인증이 완료된 후 사용자 리다이렉션에 활용됩니다.
즉, 요청에 redirect 쿼리 파라미터가 포함되어 있으면 이를 세션에 저장하여 이후 사용(예: 로그인 성공 후 리다이렉션)할 수 있도록 합니다.
지금 상황에서 당장은 필요없지만, 프론트엔드에서 현재 상황으로 다시 리다이렉트를 원할 경우 사용이 가능합니다.
GET http://서버주소/api/v1/auth/oauth2/kakao?redirect=/custom-page
로 호출을 수행하면
https://example.com/login/success?redirect=/custom-page
로 리다이렉트 되는 구조입니다.
@Component
public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver{
private static final String AUTHORIZATION_REQUEST_BASE_URI = "/api/v1/auth/oauth2";
private static final String QUERY_PARAM = "redirect";
private final OAuth2AuthorizationRequestResolver defaultAuthorizationRequestResolver;
public CustomAuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) {
this.defaultAuthorizationRequestResolver =
new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, AUTHORIZATION_REQUEST_BASE_URI);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = defaultAuthorizationRequestResolver.resolve(request);
return customizeAuthorizationRequest(request, authorizationRequest);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = defaultAuthorizationRequestResolver.resolve(request, clientRegistrationId);
return customizeAuthorizationRequest(request, authorizationRequest);
}
private OAuth2AuthorizationRequest customizeAuthorizationRequest(HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
if (authorizationRequest == null) {
return null;
}
String redirect = request.getParameter(QUERY_PARAM);
if (redirect != null) {
request.getSession().setAttribute(QUERY_PARAM, redirect);
}
return authorizationRequest;
}
}
FailedAuthenticationEntryPoint.java
로그인에 실패할 경우 백엔드 서버에서 볼 수 있는 페이지입니다. 지금 당장은 필요없지만 후에 사용이 가능합니다.
@Component
@RequiredArgsConstructor
public class FailedAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ErrorResponse error = ErrorResponse.builder()
.code("401")
.message("인증에 실패하였습니다.")
.build();
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
objectMapper.writeValue(response.getWriter(), error);
}
}
SecurityConfig.java
Spring Security의 설정 파일이며, 위에서 만들어 두었던 모든 구현체들을 설정에 등록합니다.
그 외, CORS 설정이나 엔드포인트 접근 제한(로그인 한 사람들 중 ROLE_ADMIN만 볼 수 있는 페이지, 지금은 임시로 전부 풀어둠) 등을 수행할 수 있습니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final ReevProperties reevProperties;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final DefaultOAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2FailureHandler oAuth2FailureHandler;
private final OAuth2LogoutHandler oAuth2LogoutHandler;
private final CustomAuthorizationRequestResolver customAuthorizationRequestResolver;
private final FailedAuthenticationEntryPoint failedAuthenticationEntryPoint;
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors
.configurationSource(corsConfigurationSource())
)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
// .requestMatchers(WHITE_LIST).permitAll()
// .requestMatchers(USER_LIST).hasRole("USER")
// .requestMatchers(ADMIN_LIST).hasRole("ADMIN")
// .anyRequest().authenticated()
.anyRequest().permitAll() // 임시로 전부 허용
)
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint
.authorizationRequestResolver(customAuthorizationRequestResolver))
.redirectionEndpoint(endpoint -> endpoint.baseUri("/oauth2/callback/*"))
.userInfoEndpoint(endpoint -> endpoint.userService(oAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
)
.logout(logout -> logout
.addLogoutHandler(oAuth2LogoutHandler)
.logoutUrl("/api/v1/logout")
.deleteCookies("refresh_token")
.logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK))
)
.exceptionHandling(exceptionHandling -> exceptionHandling // 실패 시 해당 메시지 반환
.authenticationEntryPoint(failedAuthenticationEntryPoint)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
@Bean
protected CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(reevProperties.getFrontUrl());
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Access-Control-Allow-Headers", "Access-Control-Expose-Headers", "_retry"));
config.addExposedHeader("Authorization"); //프론트에서 해당 헤더를 읽을 수 있게
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.requestMatchers("/index.html");
}
}
Redis는 오픈 소스 인메모리 데이터베이스로, 빠른 데이터 처리를 위해 자주 사용됩니다. 주로 캐싱, 세션 관리, 메시지 브로커 등으로 활용됩니다.
userId:RefreshToken
형식으로 데이터를 저장합니다.userId
의 Refresh Token을 삭제합니다.RedisRepository.java
Redis에서 찾기, 생성 및 수정, 삭제를 구현한 리포지토리입니다. JPA를 사용하지 않고 RedisTemplate를 사용합니다.
@Repository
@RequiredArgsConstructor
public class RedisRepository {
private final RedisTemplate<String, String> redisTemplate;
public String findByKey(String key) {
return redisTemplate.opsForValue().get(key);
}
public void save(String key, String value, Duration expiration) {
redisTemplate.opsForValue().set(key, value, expiration);
}
public void delete(String key) {
redisTemplate.delete(key);
}
}
RedisConfig.java
Redis의 설정파일입니다.
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(redisProperties.getHost());
configuration.setPort(redisProperties.getPort());
configuration.setPassword(redisProperties.getPassword());
return new LettuceConnectionFactory(configuration);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setConnectionFactory(redisConnectionFactory());
return template;
}
}
/api/v1/auth/oauth2/kakao
엔드포인트를 통해 로그인합니다.userId:RefreshToken
형식으로 저장됩니다./api/v1/auth/reissue
엔드포인트를 통해 RT를 사용하여 새로운 AT를 발급받습니다./api/v1/logout
엔드포인트를 호출하여 Redis에서 RT를 삭제하고 쿠키의 RT를 제거합니다.위의 흐름을 통해 AT 및 RT를 안전하게 관리하며 인증 및 권한 부여를 수행합니다.