[Springboot] 스프링 카카오 로그인, JWT 토큰 발급 구현(WebSecurityConfigurerAdapter Deprecated 버전)

winluck·2023년 10월 17일
50

Springboot

목록 보기
7/18


3학년 2학기 프로젝트에 스프링에서 카카오 소셜로그인 및 JWT 토큰 발급 기능이 필요하게 되어,

다양한 블로그를 참고하며 이를 직접 구현해 보았다.

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

먼저, 카카오에서 공식적으로 제공하는 REST API 기반 소셜로그인 구현 다이어그램이다. Step 2에서 Front-End가 Springboot 서버에게 카카오 서버로부터 로그인 후 반환받은 accessToken을 전달하는 것을 전제로 설명을 시작해보겠다.

build.gradle에 의존성 추가

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이다.

WebclientConfig

@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에게 전달한다. (유출되면 안 되므로 취급에 주의한다.)

  • 네이티브 앱 키: iOS, Android 앱 개발시 필요
  • REST API 키: REST API 호출시 필요
  • Javascript 키: Javascript 앱 개발시 필요
  • Admin 키: Push, 앱관리 등 관리자 권한에서 필요

KakaoAuthService

@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();
    }
}
  • 클라이언트가 보낸 accessToken을 이용해 카카오 서버에 유저 정보를 요청
  • 토큰이 유효하지 않으면 예외를 발생
  • 유효하다면 수신한 userInfo에서 유저의 고유ID를 통해 회원가입 여부를 판단
  • 만약 존재하면 유저의 id를 반환한다.
  • 존재하지 않으면 (즉 회원가입이 필요하다면) 예외를 발생

KakaoUserInfo

@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();
    }
}

동의항목 설정

  • [내 애플리케이션] - [카카오로그인] - [동의항목]
  • 나는 닉네임과 프로필 사진을 활용할 것이기에, 필수 동의로 체크하였다.
  • 이메일의 경우 심사 통과가 필요하다.

KakaoUserInfoResponse 등 Dto

@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 토큰을 발급해보자.

JwtTokenProvider

@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");
    }
}

UserDetails 상속

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) 어노테이션을 부여해주지 않으면 에러가 발생한다.

이 프로젝트에선 유저의 권한을 구체적으로 지정하진 않았으나, 추후 권한을 구분해야 할 때 다시 다루도록 하겠다.

CustomUserDetailService

@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를 상속받아 작성한다.

AuthController

@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());
    } 

이미 회원이라고 가정하고 설명하겠다.

  • isSignedUp 메서드를 통해 토큰으로 카카오 서버로부터 회원의 고유 ID를 받아 이를 DB의 user 테이블에 접근하여 회원가입 여부를 확인
  • 만약 유저가 존재한다면, 이 유저의 식별자인 id와, id 기반으로 생성된 JWT 토큰을 Map 형태로 클라이언트에게 반환한다.
  • 클라이언트는 이제 이 JWT 토큰을 API 요청 헤더에 담아 만료시간 전까지 사용할 수 있다.

다만 고민사항이 있다.

  • 토큰을 헤더에서 받긴 할 텐데, 만료 등 토큰의 유효성이 없는 상황을 판정해주는 로직이 존재해야 하지 않나?
  • 그렇다면 모든 API 요청에 이 로직이 들어가야 하는가?

이러한 고민을 해결하기 위해, 토큰 필터링을 자동으로 처리하는 시스템을 도입할 수 있다.

JwtAuthFilter

@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);
    }
}

WebSecurityConfig

  • 구현 전 유의사항

스프링 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 요청마다 자동으로 필터링할 수 있다.

카카오로그인 구현 요약

  1. Front-End가 카카오 서버로부터 받은 accessToken을 서버로 전송한다.
  2. 서버는 카카오서버에 이 토큰을 통해 유저 정보를 습득하고, 유저의 고유 id를 통해 userRepository.findBy를 통해 회원가입 여부를 검사한다.
  3. 이를 위해 User Entity는 카카오 유저정보의 고유 id(혹은 이메일 등 다른 식별성을 지닌) 속성을 저장해 두어야 하고, UserDetails 인터페이스를 상속받아 구현한다.
  4. 회원가입이 필요하다면 회원가입 관련 절차에 돌입한다. (여기선 다루지 않았다.)
  5. 회원임이 확인되었다면 유저의 PK와 Jwt 토큰을 반환한다.
  6. 유저는 Jwt 토큰을 활용해 API 요청마다 관련 인증을 통과할 수 있다.
  7. 토큰의 만료시간을 지정할 수 있으며, 특정 API 요청에 토큰 인증 절차를 포함하거나 포함하지 않을 수 있다.

좋아요는 큰 힘이 됩니다:)

참고 블로그

[Spring Boot] 스프링부트 + jwt 인증 구현하기 / Token 발급받기

[Spring] 카카오 로그인 구현하기

WebSecurityConfigurerAdapter Deprecated 대응법

profile
Discover Tomorrow

8개의 댓글

comment-user-thumbnail
2023년 10월 17일

우왕^9^

답글 달기
comment-user-thumbnail
2023년 10월 17일

잘보고 갑니다~

답글 달기
comment-user-thumbnail
2023년 10월 18일

캬 멋집니다

답글 달기
comment-user-thumbnail
2023년 10월 18일

우왕>_<

답글 달기
comment-user-thumbnail
2023년 10월 18일

멋진. 글. 잘. 읽고. 갑니다. ^^

답글 달기
comment-user-thumbnail
2023년 10월 25일

글 잘 읽었습니다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

세션을 사용하지 않는 이유는 뭘까요?

2개의 답글