개요
전체 아키텍쳐 제안(권장)
서버는 OAuth2AuthorizedClientService / OAuth2AuthorizedClientManager로 서드파티 토큰 관리
서드파티 토큰 = 카카오 네이버에서 발급한 Access Token/Refresh Token
이건 원래 카카오 API,네이버 API 같은 외부 자원서버에 접근하기 위해 필요한 거야.
Spring Security가 OAuth2AuthorizedClientService에 저장해두고 만룍되면 OAuth2AuthorizedClientManager가 알아서 Refresh Token으로 갱신해 줌.
즉, 서버가 대신 들고 다니는 카카오 / 네이버 열쇄
클라이언트에는 직접 노출하지 않고, 서버에서만 안전하게 쓴다.
예시-
OAuth2AuthorizedClient client = clientService.loadAuthorizedClient("kakao", "user123");
String kakaoAccessToken = client.getAccessToken().getTokenValue();
//서버가 직접 카카오 API호출에 사용
클라이언트는 서버발행 JWT를 사용해 API 호출
필수 의존성 application.yml예시 (provider 설정)
카카오 / 네이버는 각자 엔드포인트가 표준과 조금 다를 수 있으니 authorization-uri, token-uri, user-info=uri를 실제 문서대로 채워야 함
예시
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- profile
- account_email
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
client-name: Naver
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- name
- email
provider:
kakao:
authorization-uri: <https://kauth.kakao.com/oauth/authorize>
token-uri: <https://nid.naver.com/oauth2.0/token>
user-info-uri: <https://openapi.naver.com/v1/nid/me>
user-name-attribute: response
Security 설정
핵심
@Configuration
@EnableWebSecurity
public class SecurityConfig{
@Bean
public securityFilterChain securityFilterChain(HttpSecurity http,
CustomOAuth2UserService oAuth2UserService, OAuth2LoginSuccessHandler successHandler) throws Exception{
http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/oauth2/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth -> oauth
.userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService))
.successHandler(successHandler));
return http.build();
}
}
//코드설명
// HttpSecurity 빌더 시작점. 이 체이닝으로 Security 설정을 구성하고, 마지막에 http.build()로 SecurityFilterChain을 만든다.
2. .csrf().diasble()
1. CSRF 보호를 비활성화함
2. 왜 흔히 쓰나 : REST API(특히 JWT로 stateless 인증을 하거나, 모바일/ SPA에서 토큰으로 인증)를 제공할 때는 CSRF 토큰을 사용하지 않기 때문에 자주 끈다.
3. 주의 쿠키 기반 세션 인증인 경우 CSRF를 꺼버리면 위험. 브라우저를 통해 로그인을 제공하고 쿠키를 사용한다면 기본적으로 CSRF를 켜 두는 편이 안전하다.
4. 실무팁 : SPA + 쿠키 방식이면 CookieCsfTokenRepository 같은 안전한 설정을 고려하자.
3. .authorizeHttpRequests(auth -> auth ...)
1.요청별 접근 규칙을 선언하는 블록(누가 어떤 경로에 접근 가능한지)
2. 람다 내부에 여러 requestMathcers / mvcMathchers / antMatchers 등을 순서대로 작성 해서 허용 / 차단 룰을 만든다.
4. .requestMatchers("/api/public/**", "/oauth2/**").permitAll()
5. .anyRequest().authenticated()
1. 위에서 따로 허용한 경로를 제외한 모든 요청은 인증된 사용자만 접근 가능하다는 뜻
즉, 인증되지 않은 사용자가 나머지 API를 호출하면 401 / 302로 처리된다.
2. 즉, 인증되지 않은 사용자가 나머지 API를 호출하면 401 / 302로 처리된다.
6. .oauth2Login(oauth -> oauth ...)
1. OAuth2 로그인 관련 설정 블록
2. 이 블록 안에서 userInfoEndpoint(프로바이더에서 사용자 정보 가져오는 부분)과 successHandler을 커스터마이즈한다.
//7. .userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService)) userInfoEndpoint()
//1. userInfoEndpoint() 는 액세스 토큰으로 프로바이더의 UserInfo 엔드포인트를 호출해 사용자 속성을 얻는 부분을 제어.
//2. userService(oAuth2UserService)로 DefualtOAuth2UserService 를 대체하는 커스텀 서비스를 등록하면, provider별 attribute 매핑(카카오 / 네이버 구조 차이 처리), 로컬 사용자 생성 / 조회, 추가 검증 등을 할 수 있다.
3. 실행 시점: 토큰 교환이 끝나고, 스프링이 user-info를 조회할 때 이 서비스가 호출된다. 이 서비스에서 예외를 던지면 인증 실패 처리 됨
8. .successHandler(successHandler)
1. 로그인 성공 후 동작을 담당하는 핸들러를 지정.
2. 기본 동작은 세션에 인증 저장하고 이전 요청으로 리다이렉트하거나 기본 타깃 URL로 보낸다. 하지만 SPA나 모바일 API에서는 redirect보다 JSON으로 JWT를 내려주거나 쿠키를 설정하는 식으로 다르게 처리해야 하므로 커스텀 핸들러를 쓴다.
3. successHandler는 onAuthenticationSuccess(HttpServletRequest, HttpservletResponse, Authentication)을 구현해서 토큰 발급, DB저장, 리다이렉트. 응답 바디 작성 등을 할 수 있다.
4. 실행 순서: userService가 OAuthUser를 만들어 Authentication이 생성된 뒤 호출됨.
커스텀 OAuth2UserService - 프로바이더 응답을 앱 유저로 매핑
소셜 프로필을 로컬 user 엔티티로 매핑하고 DB에 저장(최초 로그인 시)
예시
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService{
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 프로바이더에서 사용자 정보를 가져온다.
OAuth2User oAuth2User = super.loadUser(userRequest);
// 어떤 소셜인지 식별(kakao, naver, google 등)
String regustrationId = userRequest.getClientRegistration().getRegistrationId();
//프로바이더가 반환한 attribute들을 맵으로 추출
Map<String, Object> attributes = oAuth2User.getAttributes();
// provider별로 attributes 구조가 다르니 파싱 / 정규화(팩토리/어댑터)
SocialUserInfo info = SocialUserInfoFactory.getSocialUserInfo(registrationId, attribute);
// 로컬 User 엔티티 생성 조회 업데이트
// 예: User user = userRepository.findByEmail(info.getEmail()).orElseCreate(...);
// Soring Security가 사용할 OAuth2User 구현체 반환 (권한, 속성, nameAttributeKey)
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))
attributes,
"id") //OAuth2User#getName()가 attributes.get("id")를 사용하게 됨
}
}
로그인 성공 시 JWT 발급
소셜 로그인 성공 후 서버가 자체 JWT(액세스 / 리프레시) 발급하여 클라이언트에 전달
예시
@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler{
private final JwtProvider jwtProvider;
private final RefreshTokenService refreshTokenService; //DB 저장
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
AUthentication authentication) throws IOException {
OAuth2User oAuth2User = (oAuth2User) authentication.getPrincipal();
String email =extractEmail(oAuth2User);
String accessToken = jwtProvider.createAccessToken(email);
String refreshToken = jwtProvider.createRefreshToken(email)'
refreshTokenService.save(email, refreshToken);
// 응답: 예시로 JSON body 전송
Map<String, String> tokens =Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
new objectMapper().writeValue(response.getWriter(), tokens);
}
}
OAuth2AuthorizedClientService / Manager 활용 (서드파티 토큰 갱신)
서드파티 API(카카오에 사용자 정보 추가 조회)를 서버에서 호출할 때 OAuth2AUthorizedClientService로 저장된 OAuth2AuthorizedClient에서 토큰을 꺼내 사용하고, 만료 시 자동 갱신하게 할 수 있음
예시
@Autowired
private OAuth2AuthorizedClientService authorizedClientService;
public String callKakaoApi(String principalName){
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient("kakao", principalName);
String accessToken = client.getAccessToken().getTokenValue();
//RestTemplate 또는 WebClient로 카카오 API 호출(Authorization: Bearer ...)
}
JWT 기반 세션 대체 전략 (권장)
짧은 만료 - 클라이언트가 요청마다 전송
리프레시 토큰: 서버에 저장(데이터베이스 / Redis) → 탈취 위험 줄임
리프레시 시 서버에서만 갱신하고 새 액세스 토큰 발급
장점
예시
@RestController
@RequestMapping("/auth")
public class AUthController{
private final JwtProvider jwtProvider;
private final RefreshTokenService refreshTokenService;
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody Map<String, String> body){
String refreshToken = body.get("refreshToken");
if(!jwtProvider.validateToken){
return ResponseEntity.status(HttpStatus.unauthorized).build();
}
String email = jwtProvider.getSubject(refreshToken);
of(!refreshTokenService.exists(email, refreshToken)){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}
String newAccessToken = jwtProvider.createAccessToken(email);
String newRefreshToken = jwtProvider.createRefreshToken(email);
refreshTokenService.replace(email, refreshToken, newRefreshToken);
return ResponseEntity.ok(Map.of("accessToken", newAccessToken, "refreshToken", newRefreshToken));
}
}
OAuth2.0 의 과정과 용어 설명
궁금했던 부분 SecurityContextHolder.getContext().setAuthentication(authentication)이 궁금 했습니다.