
우리 서비스는 카카오 로그인을 구현할 예정이에요.
참고로!
Spring Boot OAuth 2 client를 사용하여 구현하지 않았습니다.
클래스 직접 구현해서 사용했고(KakaoOAuth2), 추후에 리팩토링 예정임니당. .
클라이언트가 보호 리소스에 액세스할 때 access token 을 사용하여 인증 수준을 검사할 수 있다.
만약 Spring Security를 적용하지 않는다면,
access token 을 통한 검사가 이루어지지 않겠죠 ?
그럼 토큰이 없는 아이들도 보호되는 리소스에 접근할 수 있다는 말.

리소스 서버로부터 Authorization Code만 발급받으면, 그 이후는 Spring Security에서 처리를 수행한다.
프로젝트에서 사용하는 Access Token 을 발급받아서 리소스 접근자에 대해서도 토큰 검사를 실시하고 여기에 접근 시간과 제한을 둔다!

용어 정리 먼저 하고 가자.
OAuth2 흐름에서 보면,
- 클라이언트 = 우리 서버
- 리소스 오너 = 사용자
- 인증 서버(리소스 서버) = 클라이언트의 접근 자격을 확인하고 Access Token 발급해주는 구글, 카카오, 네이버 같은 아이들.
Authorization code 를 발급해 응답한다.Authorization code 를 POST 요청한다.Authorization code 로 토큰을 요청한다.Access Token & Refresh Token 발급해서 응답한다.Access Token 으로 필요한 리소스(이메일, 프로필 사진 등 유저 정보겠찌?)를 요청한다.Access Token & Refresh Token)을 응답한다.
즉, 백엔드에서는 4번부터 처리하면 되는것이다!
인증 방식: 권한 코드 승인 방식
의존성은 다음과 같이 추가했다.
SpringBoot OAuth2 client 를 사용하지 않았기 때문에 json 라이브러리를 추가했음.
그 이외는 전 게시물 참고.
# buile.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation group: 'org.json', name: 'json', version: '20160810'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
본격적으로 구현을 위해서 필요한 클래스들을 만들어주겠습니다.


이정도가 있다.
카카오 로그인 시 api 키 받고, 세팅을 해야한다.
http://localhost:9090
https://develog.co.kr
활성화 설정 ON
https://develog.co.kr/login/oauth2/code/kakao
http://localhost:9090/login/oauth2/code/kakao
https://getpostman.com/oauth2/callback
앞에 redirect_uri 만 잘 설정하면, 뒤에 /login/oauth2/code/kakao 는 정해진 uri이므로 맞춰주면 된다.
가서 원하는 거 설정해주면 된다 !
URI: users/login/kakao
http-method: POST
Request: 카카오에서 받은 Authorization code
Response: 유저정보 + JWT(Access Token+ Refresh Token)
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final JwtTokenProvider jwtTokenProvider;
private final UserService userService;
// 카카오 로그인 및 회원가입
@PostMapping("/login/kakao")
public ResultTemplate login(@RequestBody RequestLoginDto requestLoginDto) {
LoginDto user = userService.findKakaoUserByAuthorizedCode(requestLoginDto.getCode(), RedirectUrlProperties.KAKAO_REDIRECT_URL);
String accessToken = jwtTokenProvider.createAccessToken(user.getUserId(), String.valueOf(user.getUserId()), user.getSocialType());
String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId());
// 리프레시토큰 레디스에 저장한다
jwtTokenProvider.storeRefreshToken(user.getUserId(), refreshToken);
ResponseLoginDto responseLoginDto = ResponseLoginDto.builder()
.userId(user.getUserId())
.name(user.getNickname())
.AccessToken(accessToken)
.RefreshToken(refreshToken)
.build();
return ResultTemplate.builder()
.status(HttpStatus.OK.value())
.data(responseLoginDto)
.build();
}
}
SecurityConfig 파일에 작성했다.
REST API 이므로,
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtExceptionFilter jwtExceptionFilter;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.formLogin().disable()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers(
"/api/users/login/**",
"/api/users/reissue").permitAll()
.antMatchers(
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html").permitAll()
.anyRequest().authenticated()
// .antMatchers("/*").permitAll()
.and()
.cors()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtExceptionFilter, jwtAuthenticationFilter().getClass());
return http.build();
}
}
토큰 관련 처리를 해줄 JwtTokenProvider 다.
Access Token 생성 메서드Refresh Token 생성 메서드Access Token 유효한 지 체크 메서드: getAuthentication() 시큐리티가 해준다Access Token 값 리턴하는 메서드Refresh Token 값 리턴하는 메서드Refresh Token 저장하는 메서드등등 정도가 있다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private final UserDetailsService userDetailsService;
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenValidTime;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenValidTime;
private final UserRepository userRepository;
private final RedisTemplate<String, String> redisTemplate;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public String createAccessToken(Long userId, String userPk, SocialType socialType) {
// JWT payload 에 저장되는 정보단위, 보통 여기서 user를 식별하는 값을 넣음
Claims claims = Jwts.claims().setSubject(userPk);
claims.put("id", userId);
claims.put("socialType",socialType);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + accessTokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature 에 들어갈 secret값 세팅
.compact();
}
public String createRefreshToken(Long userId) {
Date now = new Date();
return Jwts.builder()
.setId(Long.toString(userId)) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + refreshTokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature 에 들어갈 secret값 세팅
.compact();
}
public Authentication getAuthentication(String token) {
log.info("여기 토큰 userId로 던짐!!:::: " + this.getUserPk(token));
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
log.info("JwtTokenProvider 클래스 들어옴. getAuthentication 메서드 실행 중");
return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
}
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
public String getUserId(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getId();
}
// Request의 Header에서 accesstoken 값을 가져옴. "Authorization" : "ACCESSTOKEN값'
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader("Authorization"));
}
// Request의 Header에서 refreshtoken 값을 가져옴. "Authorization-Refresh" : "REFRESHTOKEN값'
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader("Authorization-Refresh"));
}
public void storeRefreshToken(long userId, String refreshToken) {
User user = userRepository.findById(userId).orElse(null);
if (user != null) {
redisTemplate.opsForValue().set(
Long.toString(userId),
refreshToken,
refreshTokenValidTime,
TimeUnit.MILLISECONDS
);
}
}
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (SignatureException e) {
log.warn("JWT 서명이 유효하지 않습니다.");
throw new SignatureException("잘못된 JWT 시그니쳐");
} catch (MalformedJwtException e) {
log.warn("유효하지 않은 JWT 토큰입니다.");
throw new MalformedJwtException("유효하지 않은 JWT 토큰");
} catch (ExpiredJwtException e) {
log.warn("만료된 JWT 토큰입니다.");
throw new ExpiredJwtException(null, null, "토큰 기간 만료");
} catch (UnsupportedJwtException e) {
log.warn("지원되지 않는 JWT 토큰입니다.");
throw new UnsupportedJwtException("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.warn("JWT claims string is empty.");
} catch (NullPointerException e) {
log.warn("JWT RefreshToken is empty");
} catch (Exception e) {
log.warn("잘못된 토큰입니다.");
}
return false;
}
}
이 아이의 역할은 뭐냐?
어떤 요청이 오면 필터인 이 아이가 잡아서 다음 코드를 수행한다.
해당 필터에서는
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("시큐리티 인증 필터 진입함 ::: doFilterInternal 메서드");
// 헤더에서 JWT 받는다
String accessToken = jwtTokenProvider.extractAccessToken(request).orElse(null);
log.info("추출한 Access Token:::: " + accessToken);
// 유효 토큰 검사
if(accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
// 유효한 토큰일 때 토큰을 통해 유저 정보를 받아온다
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
// SecurityContext 요기다가 Authentication 객체를 저장한다.
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("유효 토큰 검사 완료. 해당 유저 인가 처리 완료");
}
// null 일때 처리: 더 들어오지 않고 내보낸다 - 일단 보류요 ..
filterChain.doFilter(request, response);
}
}
얘는 jwt 관련한 예외처리를 하는 필터이다.
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response); // JwtAuthenticationFilter로 이동
} catch (JwtException ex) {
// JwtAuthenticationFilter에서 예외 발생하면 바로 setErrorResponse 호출
setErrorResponse(request, response, ex);
}
}
private void setErrorResponse(HttpServletRequest req, HttpServletResponse res, JwtException ex) throws IOException {
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
final Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
// ex.getMessage() 에는 jwtException을 발생시키면서 입력한 메세지가 들어있다.
body.put("message", ex.getMessage());
body.put("path", req.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
mapper.writeValue(res.getOutputStream(), body);
}
}
우리 프로젝트는 ssl/tls 인증서를 통해 https 통신을 하여 데이터가 안전하게 통신된다. 따라서 header나 body 둘다 상관없이 보안이 유지된다.
하지만 일반적인 http 통신에서는 header가 맞는 듯하다.
gpt 답변은 아래와 같다.
결론만 말하자면 민감한 정보는 Header에 넣는게 더 옳다.

우리 서비스에서는 API 명세에 적힌대로,
응답 본문에 json형태로 보내도록 했습니당
다시 돌아와서!
JwtAuthenticationFilter 에 진입하는 시점:프론트에서 카카오 서버에 가서 로그인을 성공하면 Authorization Code 를 받아와
걔가 인증이 됨과 동시에 리다이렉트를 해서 다음과 같은 url 로 이동됨.
http://localhost:9090/login/oauth2/code/kakao?code=31CGcD_pRyUg9_2N42HM8vhuPY3cGVNXUn9_dUUPbWXTCF2f2oDq_z82xk8KPXWbAAABi2qL5GndCc_9be4aqQ
여기는 프론트랑 얘기를 해서 리다이렉트를 어디로 해줄지 정하면 되고,
(아마 메인페이지가 나오겠지)
code를 백으로 보내면, 이때! 시큐리티 인증 필터에 진입해.
2023-10-26 14:58:44.311 INFO 23537 --- [nio-9090-exec-5] c.s.d.s.jwt.JwtAuthenticationFilter : 시큐리티 인증 필터 진입함 ::: doFilterInternal 메서드


여기서 계속 에러가 난다.
해결 어캐 했냐면,
내가 AccessToken 을 생성할 때 JWT를 만드는 과정에서 Claim이라는 객체를 만들고 하는데 여기서 Subject라고 하는 토큰을 구분하는 아이에 실수로 인해 null값을 계속 넣고 있었다.
바보 ...
여기에 구분할 수 있는데 현재는 user의 pk인 userId 뿐이라 이걸 넣어줘서 구분할 수 있게 했다.
근데 여기서 코드를 뜯어보다 보니까 이 UserId로 어떻게 시큐리티가 사용자가 유효한 토큰을 가지고 있는지 확인하지??? 의문이었는데
또 한가지 안한게 있었다 !
내가 시큐리티에서 User를 찾는 인터페이스인 UserDetails, UserDetailsService 를 구현하지 않았었다.
이거 구현하니까 됐다.
자세한 건 나중에!
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findById(Long.valueOf(username))
.orElseThrow(() -> new UsernameNotFoundException("해당하는 사용자가 없습니다"));
return new UserDetailsImpl(user);
}
}
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collectors = new ArrayList<>();
collectors.add(()->{return "ROLE_" + user.getRole();});
return collectors;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return String.valueOf(user.getUserId());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
이 에러를 해결하며 시큐리티와 한발짝 더 친해지게 된 것 같다.
잘 복붙했다면 이런것도 몰랐겠지
고맙다 나의 실수야~