3학년 2학기 프로젝트에 스프링에서 카카오 소셜로그인 및 JWT 토큰 발급 기능이 필요하게 되어,
다양한 블로그를 참고하며 이를 직접 구현해 보았다.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
먼저, 카카오에서 공식적으로 제공하는 REST API 기반 소셜로그인 구현 다이어그램이다. Step 2에서 Front-End가 Springboot 서버에게 카카오 서버로부터 로그인 후 반환받은 accessToken을 전달하는 것을 전제로 설명을 시작해보겠다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation platform('org.springframework.boot:spring-boot-dependencies:2.7.15')
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2'
implementation "io.jsonwebtoken:jjwt:0.9.1"
카카오로그인을 위한 oauth, webclient, jwt 관련 의존성을 추가했다.
Java 11, Springboot 버전 2.7.15이다.
@Configuration
public class WebClientConfig {
@Bean
public ReactorResourceFactory resourceFactory() {
ReactorResourceFactory factory = new ReactorResourceFactory();
factory.setUseGlobalResources(false);
return factory;
}
@Bean
public WebClient webClient() {
Function<HttpClient, HttpClient> mapper = client -> HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(10))
.addHandlerLast(new WriteTimeoutHandler(10)))
.responseTimeout(Duration.ofSeconds(1));
ClientHttpConnector connector =
new ReactorClientHttpConnector(resourceFactory(), mapper);
return WebClient.builder().clientConnector(connector).build();
}
}
[애플리케이션 추가하기]로 새 애플리케이션을 추가하면 앱 키를 받을 수 있고, 이를 Front-End에게 전달한다. (유출되면 안 되므로 취급에 주의한다.)
@RequiredArgsConstructor
@Service
public class KakaoAuthService {
private final KakaoUserInfo kakaoUserInfo;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Long isSignedUp(String token) {
KakaoUserInfoResponse userInfo = kakaoUserInfo.getUserInfo(token);
User user = userRepository.findByKeyCode(userInfo.getId().toString()).orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND));
return user.getId();
}
}
@RequiredArgsConstructor
@Component
public class KakaoUserInfo { // 카카오 API를 이용해 토큰을 전송하여 유저 정보를 요청
private final WebClient webClient;
private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me";
public KakaoUserInfoResponse getUserInfo(String token) {
Flux<KakaoUserInfoResponse> response = webClient.get()
.uri(USER_INFO_URI)
.header("Authorization", "Bearer " + token)
.retrieve()
.bodyToFlux(KakaoUserInfoResponse.class);
return response.blockFirst();
}
}
@Getter
public class KakaoUserInfoResponse {
private Long id;
private String connected_at;
private KakaoProperties properties;
private KakaoAccount kakao_account;
}
@Getter
public class KakaoProperties {
private String nickname;
private String profile_image;
private String thumbnail_image;
}
@Getter
public class KakaoAccount {
private Boolean profile_nickname_needs_agreement;
private Boolean profile_image_needs_agreement;
private KakaoProfile profile;
}
@Getter
public class KakaoProfile {
private String nickname;
private String thumbnail_image_url;
private String profile_image_url;
private Boolean is_default_image;
}
토큰을 전송한 결과로 받게 될 유저 정보에 대한 DTO이다. 여기서 이름, 프로필 사진 등을 얻어 회원가입 시 활용할 수 있다.
만약 위와 달리 자신의 동의항목이 다양하고 복잡하여 구체적인 Dto를 결정하지 못하겠다면, 사용자의 accessToken을 직접 타임리프 등을 통해 화면 상에서 토큰을 위한 동작을 구현한 뒤 이를 받아 Postman 등에 전송하면, 내가 어떤 Dto를 구상해야 할 지 알 수 있다.
실제로 닉네임과 프사만 필수 동의로 설정하고 accessToken을 헤더에 담아 전송하면, 카카오 서버에서 반환하는 응답 JSON 데이터는 아래와 같다. (2023/11/6 기준)
{
"id": Long,
"connected_at": "string",
"properties": {
"nickname": "string",
"profile_image": "string",
"thumbnail_image": "string"
},
"kakao_account": {
"profile_nickname_needs_agreement": false,
"profile_image_needs_agreement": false,
"profile": {
"nickname": "string",
"thumbnail_image_url": "string",
"profile_image_url": "string",
"is_default_image": false
}
}
}
관련된 카카오 REST API 내용은 아래와 같다.
회원번호 id는 각 프로젝트별, 유저별로 고유값으로 부여된다. 우리는 이를 User 테이블에 속성으로 저장함으로써, 회원가입 여부를 판정할 때 활용한다.
이제 Jwt 토큰을 발급해보자.
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}") // application.properties 등에 보관한다.
private String secretKey;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// 토큰 생성
public String createToken(String userPk) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + (30 * 60 * 1000L))) // 토큰 유효시각 설정 (30분)
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘과, secret 값
.compact();
}
// 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// 토큰 유효성, 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
// Request의 Header에서 token 값 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
}
public class User implements UserDetails {
// Jwt 전용 설정 (UserDetails 인터페이스 구현)
@Column(length = 100, nullable = false, unique = true)
private String keyCode; // 로그인 식별키
@ElementCollection(fetch = FetchType.EAGER) //roles 컬렉션
private List<String> roles = new ArrayList<>();
@Override //사용자의 권한 목록 리턴
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return keyCode;
}
@Override
public String getPassword() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// Jwt 전용 설정 종료
// 그 외 유저의 다른 속성 및 메서드...
}
Spring Security가 인증 절차 수행을 위한 User의 정보를 담는 인터페이스가 UserDetails이기에, 이를 상속받아 구현이 요구되는 메서드를 위와 같이 구현해야 한다.
참고로 이 프로젝트에서 인증을 위해 필요한 key에 해당하는 속성인 keyCode에 @Column(length = 100, nullable = false, unique = true) 어노테이션을 부여해주지 않으면 에러가 발생한다.
이 프로젝트에선 유저의 권한을 구체적으로 지정하진 않았으나, 추후 권한을 구분해야 할 때 다시 다루도록 하겠다.
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String keyCode) throws UsernameNotFoundException {
return userRepository.findByKeyCode(keyCode)
.orElseThrow(() -> new UsernameNotFoundException(ResponseCode.USER_NOT_FOUND.getMessage()));
}
}
Spring Security에서 요구하는 인증 절차 수행을 위하여,
유저의 고유번호(여기선 keyCode)를 통해서 userRepository에서 유저를 찾는 CustomUserDetailService를 UserDetailsService를 상속받아 작성한다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
private final KakaoAuthService kakaoAuthService;
private final JwtTokenProvider jwtTokenProvider;
// 카카오 로그인을 위해 회원가입 여부 확인, 이미 회원이면 Jwt 토큰 발급
@PostMapping("/login")
public ApiResponse<HashMap<Long, String>> authCheck(@RequestHeader String accessToken) {
Long userId = kakaoAuthService.isSignedUp(accessToken); // 유저 고유번호 추출
HashMap<Long, String> map = new HashMap<>();
map.put(userId, jwtTokenProvider.createToken(userId.toString()));
return ApiResponse.success(map, ResponseCode.USER_LOGIN_SUCCESS.getMessage());
}
이미 회원이라고 가정하고 설명하겠다.
다만 고민사항이 있다.
이러한 고민을 해결하기 위해, 토큰 필터링을 자동으로 처리하는 시스템을 도입할 수 있다.
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 클라이언트의 API 요청 헤더에서 토큰 추출
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효성 검사 후 SecurityContext에 저장
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 다음 필터링
chain.doFilter(request, response);
}
}
스프링 2.7.0 이상의 버전을 사용하면 스프링 시큐리티 5.7.0 혹은 이상의 버전과 의존성을 갖는데, 기존에 WebSecurityConfig 클래스에서 주로 상속받아 오버라이딩했던 WebSecurityConfigurerAdapter는 2022년 2월부로 deprecated되었다.
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic()
.and()
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll();
}
}
즉 위와 같은 코드는 deprecated되었다는 것이고, 아래와 같은 형태로 변경해야 한다.
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
// 세션 사용 안함
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 회원가입, 로그인 관련 API는 Jwt 인증 없이 접근 가능
.antMatchers("/api/auth/**").permitAll()
// 나머지 모든 API는 Jwt 인증 필요
.anyRequest().authenticated()
.and()
// Http 요청에 대한 Jwt 유효성 선 검사
.addFilterBefore(new JwtAuthFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
이를 통해 Jwt 토큰의 유효성을 매 API 요청마다 자동으로 필터링할 수 있다.
좋아요는 큰 힘이 됩니다:)
우왕^9^