Spring security jwt Oauth2 로그인 - 구현(2)
전체코드 : https://github.com/gks930620/spring_securty_all
구현 자체는 JWT access token,refresh token에
Oauth2 몇몇 기능을 추가한 거밖에 없다.
그래서 여기는 전체코드를 전부 설명하는것이 아니라 추가된 내용만 작성하는걸로 하고
전체코드는 github를 참고하도록 하자.
Custom-oauth2/login/kakao 컨트롤러

이 떄 이 url은 permitALL 되어야한다.
Oauth2LoginController
@Controller
@RequestMapping("/custom-oauth2/login")
@RequiredArgsConstructor
public class Oauth2LoginController {
private final InMemoryAuthorizationRequestRepository authorizationRequestRepository;
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String kakaoClientId;
@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String kakaoRedirectUri;
@RequestMapping("/kakao")
public ResponseEntity<?> kakaoOauth2Login(HttpServletRequest request, HttpServletResponse response){
String state=""+UUID.randomUUID();
// ✅ OAuth2AuthorizationRequest 직접 생성 == 로그인사용자가 로그인페이지 요청한사용자인지 확인하는 용. 클라이언트 구별!!
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri("https://kauth.kakao.com/oauth/authorize")
.clientId(kakaoClientId)
.redirectUri(kakaoRedirectUri)
.state(state)
.attributes(attrs -> attrs.put("registration_id", "kakao"))
.build();
authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
//기본은 session에 저장해서 구별하는건데 여기서는 session없으니까 그냥 map에다.. 서버여러대면 DB 등
// ✅ Authorization URL 생성
String authorizationUrl = "https://kauth.kakao.com/oauth/authorize"
+ "?client_id=" + kakaoClientId
+ "&redirect_uri=" + kakaoRedirectUri
+ "&response_type=code"
+ "&state=" + state;
return ResponseEntity.ok(Map.of("authorizationUrl", authorizationUrl)); //client에는 카카오 restAPI문서에서 요구하는 형태로!
}
}
InMemoryAuthorizationRequestRepository
@Component
public class InMemoryAuthorizationRequestRepository implements
AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
private final Map<String, OAuth2AuthorizationRequest> authorizationRequests = new ConcurrentHashMap<>();
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
String state = request.getParameter("state");
if (state == null) {
return null;
}
return authorizationRequests.get(state);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
return;
}
String state = authorizationRequest.getState();
authorizationRequests.put(state, authorizationRequest);
System.out.println("✅ OAuth2AuthorizationRequest 저장: " + state);
// 5분 후 자동 삭제 (보안 상 Authorization Request를 계속 들고 있을 필요 없음)
new Thread(() -> {
try {
TimeUnit.MINUTES.sleep(5);
authorizationRequests.remove(state);
System.out.println("🗑️ OAuth2AuthorizationRequest 만료 삭제: " + state);
} catch (InterruptedException ignored) {}
}).start();
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,HttpServletResponse response) {
String state = request.getParameter("state");
if (state == null) {
return null;
}
System.out.println("🚀 OAuth2AuthorizationRequest 조회 후 삭제: " + state);
return authorizationRequests.remove(state);
}
}
Oauth2LoginSuccessHandler
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
private final RefreshRepository refreshRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
CustomUserAccount customUserAccount = (CustomUserAccount) authentication.getPrincipal();
String accessToken = jwtUtil.createAccessToken(customUserAccount.getUsername());
String refreshToken=jwtUtil.createRefreshToken(customUserAccount.getUsername());
// ✅ 새 Refresh Token 저장 (기존 Token 삭제 X)
RefreshEntity refreshEntity = new RefreshEntity();
refreshEntity.setUserEntity(customUserAccount.getUserEntity()); // 이전 필터에서 사용자정보저장할 떄 userEntity는 entity상태로 저장됨.
refreshEntity.setToken(refreshToken);
refreshRepository.save(refreshEntity);
// 토큰을 응답에 포함
response.setContentType("application/json");
// response.getWriter().write("{\"access_token\": \"" + accessToken + "\"}");
response.getWriter().write(new ObjectMapper().writeValueAsString(
Map.of("access_token", accessToken, "refresh_token", refreshToken)
));
}
}
이후 로그인성공을 했다면 클라이언트에게 JWT 방식에 맞게 access token, refresh token을 보내주면 된다.
successHandler는 위의 config에 successHandler 등록하면 된다.

SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService; //내가 빈으로 등록한것들
private final CustomOAuth2UserService customOAuth2UserService;
private final AuthenticationConfiguration authenticationConfiguration; //authenticationManger를 갖고있는 빈.
private final RefreshRepository refreshRepository;
private final AuthorizationRequestRepository authorizationRequestRepository;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http //내부H2DB 확인용. 진짜 1도 안중요함.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/h2-console/**").permitAll() // H2 콘솔 접근 허용
)
.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) // H2 콘솔 CSRF 비활성화
.headers(
headers -> headers.frameOptions(frame -> frame.disable())); // H2 콘솔을 iframe에서 허용
http //기본 session방식관련 다 X
.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable())
.logout(logout -> logout.disable()) //기본 로그아웃 사용X
.httpBasic(basic -> basic.disable());
http //경로와 인증/인가 설정.
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/login", "/api/join", "/api/refresh/reissue" , "/custom-oauth2/login/**") .permitAll() // oauth2도 추가
.requestMatchers("/api/my/info","/api/logout").authenticated() //security 기본 로그아웃 url인 /logout은 사용X
);
http.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authEndpoint -> authEndpoint
.authorizationRequestRepository(authorizationRequestRepository)) // ✅ 직접 구현한 저장소 적용
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2LoginSuccessHandler) // ✅ 로그인 성공 시 JWT 발급
.failureHandler((request, response, exception) -> {
exception.printStackTrace();
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}) // ✅ 실패 시 로그 찍기
);
http //필터
.userDetailsService(customUserDetailsService)
.addFilterAt(
new JwtLoginFilter(authenticationConfiguration.getAuthenticationManager(), jwtUtil,
refreshRepository),
UsernamePasswordAuthenticationFilter.class) //기존 세션방식의 로그인 검증필터 대체.
.addFilterBefore(
new JwtAccessTokenCheckAndSaveUserInfoFilter(jwtUtil, customUserDetailsService),
UsernamePasswordAuthenticationFilter.class);
http
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
String errorCause =
request.getAttribute("ERROR_CAUSE") != null ? (String) request.getAttribute(
"ERROR_CAUSE") : null;
//인증없이(access token없이) 인증필요한 곳에 로그인했을 떄.
if (errorCause == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"인증이 필요합니다.\"}");
return;
}
// JwtAccessTokenCheckAndSaveUserInfoFilter 토큰체크하는부분
if (errorCause.equals("토큰만료")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 응답
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"error\": \"Access Token expired\"}");
return;
}
if (errorCause.equals("로그인실패")) { //jwtLoginFilter 로그인시도부분.
response.setStatus(
HttpServletResponse.SC_UNAUTHORIZED); //로그인실패도 401로 하는게 보통
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"아이디 비번 틀림.\"}");
return;
}
})
);
return http.build();
}
}
CustomOauth2UserService
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Autowired
private UserRepository userRepository;
@Transactional(readOnly = false)
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 카카오로그인이든 폼로그인이든 똑같은 서비스를 제공하기 위해 DB 저장
OAuth2User oAuth2User = super.loadUser(userRequest);// user-info-uri : https://kapi.kakao.com/v2/user/me 에서 유저정보를 가져옴.
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //"kakao"
// OAuth2 제공자(Kakao)에서 가져온 사용자 정보
Map<String, Object> attributes = oAuth2User.getAttributes();
/**
{
"id": 1234567890, 문자아님 Long
"kakao_account": {
"email": "user@example.com",
"profile": {
"nickname": "홍길동"
}
}
}
*/
Long id= (Long) attributes.get("id"); //카카오는 id가 Long.
Map<String,Object> kakaoAccount=(Map<String,Object>)attributes.get("kakao_account");
String email= (String) kakaoAccount.get("email");
Map<String,Object> profile=(Map<String,Object>)kakaoAccount.get("profile");
String nickname=(String)profile.get("nickname");
// DB에서 사용자 조회 (없으면 생성)
UserEntity user = userRepository.findByUsername("kakao"+id);
if (user == null) { //카카오로 처음 로그인하는 분
user = new UserEntity();
user.setUsername("kakao"+id);
user.setPassword("{noop}oauth2user"); // OAuth2 로그인은 비밀번호 없음. {noop}은 security가 암호화 안된 비밀번호임을 암시.
user.setEmail(email);
user.setNickname(nickname);
user.getRoles().add("USER");
user.setProvider(registrationId);
userRepository.save(user);
}else{ //처음 로그인은 아님. 카카오에서 nickname변경됐다면 우리 DB에도 반영해야지
//username password는 안 바뀜.
user.setEmail(email);
user.setNickname(nickname);
}
//CustomUserAccount로 반환 (OAuth2User + UserDetails 통합)
return new CustomUserAccount(user, attributes);
}
}
CustomUserAccount (getUserEntity 추가)
package com.security.jwt.model;
import com.security.jwt.entity.UserEntity;
import java.util.Collection;
import java.util.Map;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
public class CustomUserAccount implements UserDetails, OAuth2User {
private UserEntity userEntity;
private final Map<String, Object> attributes; // OAuth2 로그인
public CustomUserAccount(UserEntity userEntity) { //일반 사용자로 로그인 한 경우
this.userEntity = userEntity;
this.attributes=null;
}
public CustomUserAccount(UserEntity userEntity, Map<String, Object> attributes) { //Oauth2로 로그인한 경우
this.userEntity = userEntity;
this.attributes=attributes;
}
public UserEntity getUserEntity() {
return userEntity;
}
// userDetails
@Override
public String getUsername() {
return userEntity.getUsername();
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
//이 메소드는 oauth2User와 UserDetails의 getAuthroties()를 전부 override
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return userEntity.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.toList();
}
// Oauth2User
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
// 카카오로그인이든 폼로그인이든 똑같은 서비스를 제공하기 위해 DB 저장했던 userEntity 사용
/**
* oauth2User는 사용자를 getName()으로 식별하지만, 통합User객체를 사용하기때문에
* userDetails와 맞춰줌.
*/
@Override
public String getName() { //Spring SecurityContext에서 OAuth2 로그인한 사용자의 식별자로 사용됨.
return getUsername();
}
public String getEmail() {
return userEntity.getEmail();
}
public String getNickname() {
return userEntity.getNickname();
}
public String getProvider(){
return userEntity.getProvider();
}
}
로그인과정

우리가 설정할건 카카오로 인가코드 보내기 전에 Oauth2AuthorizationRequest를 검사하는 것이다.
이는 SeucirtyConfig의 authorizationRequestRepository를 위에서 만든
InMemoryAuthorzationRequestRepository로 지정해주면 된다.
http.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authEndpoint -> authEndpoint
.authorizationRequestRepository(authorizationRequestRepository)) // ✅ 직접 구현한 저장소 적용
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2LoginSuccessHandler) // ✅ 로그인 성공 시 JWT 발급
.failureHandler((request, response, exception) -> {
exception.printStackTrace();
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}) // ✅ 실패 시 로그 찍기
);
실행결과

로그인페이지로 요청 후 authorizationURL을 받는다.

authorizationURL로 요청하면 다음과 같은 화면이 나온다.
(지금 우리 서버는 백엔드서버지만 딱히 로그인페이지에서 id,pw를 입력할 클라이언트를 안 만들어서
브라우저로 진행한다.)


브라우저는 로그인 후 자동으로 redirect-uri로 요청한다.
로그를 보면 /login/oauth2/code/kakao?code= 가 있다. 인가코드가 포함된 요청이다.
(브라우저가 아닐 때는 redirect-uri로 클라이언트가 직접 백엔드서버에 요청하면 된다.)
브라우저가 /login/oauth2/code/kakao?code= 로 요청을 하는 순간
우리 서버에서는 카카오 인증서버 + 리소스 서버 요청해서 데이터받고 자체 DB에 저장하는 과정을 다 한다.
이 후 OAuth2LoginSuccessHandler의 onAuthenticationSuccess메소드에서
response에 직접 access_token과 refresh_token을 json 형태로 담아 return 했기에
브라우저화면에 표시된다.

이후 이 access token으로 /my/info에 요청하면 내 정보를 보게된다.
나머지 토큰만료, refresh 토큰등은 일반회원과 똑같이 처리된다.
(카카오로 로그인을 했다면 일반회원처럼 DB에 저장되어있기떄문에)
※ 사실 이 글에서는 Client-side를 post-man과 브라우저로 하기 때문에 브라우저에
access_token과 refresh_token이 노출되었다. Client -side가 브라우저일 때는 cookie 등에
포함시켜 노출을 피해야 한다.
클라이언트가 브라우저 ,앱 등 다양하고, 하나의 서버에서 Oauth2 JWT 를 처리하려한다면
OAuth2LoginSuccessHandler의 onAuthenticationSuccess메소드 안에서
다음과 같이 따로 처리해줄 필요가 있다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
if(브라우저라면){
쿠키에 token 감추기
return ;
}
if( 앱이라면){
response.setContentType("application/json");
response.getWriter().write(new ObjectMapper().writeValueAsString(
Map.of("access_token", accessToken, "refresh_token", refreshToken)
));
return ;
}
if(그외 기타 등등){
}
}