[Springboot] jwt + kakao, apple login 처음부터 따라해보기

AKMUPLAY·2024년 1월 17일
1

DO-SOPT-SERVER

목록 보기
3/3
post-thumbnail

작년 여름에 처음 프로젝트를 해보면서 개인적으로 가장 어려웠던 점을 뽑으라면 당연히 jwt이다.

일단 기간이 촉박해 jwt를 공부하고 적용시킬 시간도 없었기 때문에 구글링을 하며 무지성 복붙을 하기도 바빴고 그로 인해 이게 왜 돌아가는지조차 이해를 하지 못했다.

이번 겨울에 Sopt에서 하는 앱잼 프로젝트에서는 jwt 코드에 대해 미리 공부해보고 전반적인 흐름을 파악하여 적용시킨 덕분에 큰 문제 없이 jwt 부분을 마무리할 수 있었다.

다른 블로그에서는 클래스 단위로 코드를 보여주면서 설명하기 때문에 처음 하는 사람이 jwt를 쉽게 적용시키기 어렵다고 생각한다.

나도 이러한 고충을 겪었고 나 같이 힘들어하는 사람이 없었으면 하기 때문에 메서드 단위로 설명하면서 처음부터 끝까지 jwt, 그리고 더 나아가 소셜 로그인까지 완성해보는 포스트를 작성해보고자 한다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.chan'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

    implementation 'com.google.code.gson:gson:2.8.9'
}

tasks.named('test') {
    useJUnitPlatform()
}

application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/[본인이 만든 database 이름]
    username: [mysql 계정 이름]
    password: [mysql 계정 비번]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate.format_sql: true

jwt:
  secret: [본인이 설정한 jwt secret key]

[본인 ~~~] 부분은 본인의 것을 채우면 된다.

jwt 세팅

우선 jwt 세팅을 시작해보자.

  1. config 폴더를 생성해준 후, ValueConfig 클래스를 아래와 같이 생성해주자.
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

@Configuration
@Getter
public class ValueConfig {

    @Value("${jwt.secret}")
    private String secretKey;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
    }
}

위 코드는 jwt secretKey를 application.yml 파일으로부터 받아와서 Base64 인코딩된 형태로 바꿔 저장해두는 코드이다.

  1. jwt 폴더를 생성해 JwtTokenProvider 클래스를 아래와 같이 생성해주자.
import com.chan.lab.config.ValueConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final ValueConfig valueConfig;
}

3. 토큰을 생성해주는 generateToken 메서드를 추가해주자
public String generateToken(Authentication authentication, long expiration) {
    return Jwts.builder()
            .setHeaderParam(TYPE, JWT_TYPE)
            .setClaims(generateClaims(authentication))
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
}

Authentication에서 사용자 정보를 추출하고 expiration을 통해 토큰 만료시간을 설정한 다음 jwt를 생성해주는 메서드이다. 사용자 정보를 추출해주는 generateClaims()에 대해 알아보자.

  1. 사용자 정보를 추출해주는 generateClaims 메서드를 추가해주자.
private Claims generateClaims(Authentication authentication) {
    Claims claims = Jwts.claims();
    claims.put("memberId", authentication.getPrincipal());
    return claims;
}

위 메서드는 jwt에 포함된 Claims을 생성해주는 코드이다.
"memberId"를 키로 하고 authentication에서 principal 정보를 추출하여 Claims에 추가해주고 이를 반환해준다.


5. jwt를 서명할 때 사용되는 secretKey를 생성해주는 getSigningKey 메서드를 추가해주자.

private SecretKey getSigningKey() {
    String encodedKey = getEncoder().encodeToString(valueConfig.getSecretKey().getBytes());
    return hmacShaKeyFor(encodedKey.getBytes());
}

위 메서드는 설명 그대로 jwt를 서명할 때 사용되는 secretKey를 생성해준다.

  1. jwt 폴더에 JwtAuthenticationFilter Class를 아래와 같이 생성해주자.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String BEARER_HEADER = "Bearer ";
    private static final String BLANK = "";
    
    private final JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    }
}

spring security에 등록할 JwtAuthentiacationFilter이다.
jwt를 인증하기 위한 코드를 여기에 작성할 것이다.


7. 요청에서 액세스 토큰을 추출해주는 getAccessTokenFromRequest 메서드 추가해주자.

private String getAccessTokenFromRequest(HttpServletRequest request) {
    return isContainsAccessToken(request) ? getAuthorizationAccessToken(request) : null;
}

private boolean isContainsAccessToken(HttpServletRequest request) {
    String authorization = request.getHeader(AUTHORIZATION);
    return authorization != null && authorization.startsWith(BEARER_HEADER);
}

private String getAuthorizationAccessToken(HttpServletRequest request) {
    return request.getHeader(AUTHORIZATION).replaceFirst(BEARER_HEADER, BLANK);
}

위 세 메서드는 요청에 담긴 request에서 "authorization" 헤더를 추출하고 앞이 "Bearer "로 시작한다면 "Bearer "을 제거한 나머지 문자열을 토큰으로 가져오는 메서드이다.

jwt를 인증하기 위한 코드를 작성하기 전에 사전 작업을 할 것이다.

  1. jwt 폴더에 JwtValidationType Enum을 아래와 같이 생성해주자.
public enum JwtValidationType {
    VALID_JWT,
    INVALID_JWT_SIGNATURE,
    INVALID_JWT_TOKEN,
    EXPIRED_JWT_TOKEN,
    UNSUPPORTED_JWT_TOKEN,
    EMPTY_JWT
}

jwt 관련 에러를 나타내는 enum이다.


9. JwtTokenProvider에 토큰을 검증해주는 메서드를 아래와 같이 추가해주자.
public JwtValidationType validateToken(String token) {
    try {
        getBody(token);
        return VALID_JWT;
    } catch (MalformedJwtException exception) {
        log.error(exception.getMessage());
        return INVALID_JWT_TOKEN;
    } catch (ExpiredJwtException exception) {
        log.error(exception.getMessage());
        return EXPIRED_JWT_TOKEN;
    } catch (UnsupportedJwtException exception) {
        log.error(exception.getMessage());
        return UNSUPPORTED_JWT_TOKEN;
    } catch (IllegalArgumentException exception) {
        log.error(exception.getMessage());
        return EMPTY_JWT;
    }
}
  1. JwtTokenProvider에 토큰에서 Claims 정보를 추출해주는 getBody 메서드를 추가해주자.
private Claims getBody(final String token) {
    return Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token)
            .getBody();
}

위 메서드는 7의 과정을 거친 토큰에서 Claims을 추출해준다.
그리고 이를 9번째 과정에서 작성한 validateToken 메서드를 통해 유효한 jwt인지 검증한다.

  1. JwtTokenProvider에 getUserFromJwt 메서드를 추가해주자.
public Long getUserFromJwt(String token) {
    Claims claims = getBody(token);
    return Long.parseLong(claims.get("memberId").toString());
}

위 메서드를 통해 우리는 토큰에서 claims 정보를 추출하고 그 claims 안에 "memberId"라는 키로 저장된 값을 Long 타입으로 반환해줄 수 있다.


  1. JwtAuthenticationFilter에 memberId를 받아올 getMemberId를 추가해주자.
private long getMemberId(String token) {
    return jwtTokenProvider.getUserFromJwt(token);
}

13. jwt 폴더에 UserAuthentication 클래스를 아래와 같이 생성해주자.
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

public class UserAuthentication extends UsernamePasswordAuthenticationToken {

    public UserAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
    }
}

사용자 인증을 나타내는 데 사용되는 UserAuthentication 클래스이다.

  1. JwtAuthenticationFilter의 doFilterInternal 메서드 안에 아래 코드를 추가해주자.
try {
    val token = getAccessTokenFromRequest(request);
    if (hasText(token) && jwtTokenProvider.validateToken(token) == VALID_JWT) {
        val authentication = new UserAuthentication(getMemberId(token), null, null);
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
} catch (Exception exception) {
    log.error(exception.getMessage());
}

filterChain.doFilter(request, response);

위 메서드에서는
7의 과정을 거친 토큰이 null이 아니고 유효하다면
jwt을 분석하여 사용자의 식별자를 추출한 후,
이를 기반으로 한 UserAuthentication 객체를 생성하고,
이 객체를 현재 보안 컨텍스트에 설정하여 애플리케이션 내의 다른 부분에서 현재 인증된 사용자에 대한 정보를 사용할 수 있도록 하는 코드다.


15. jwt 폴더에 CustomJwtAuthenticationEntryPoint를 아래와 같이 생성해주자.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.soptie.server.common.dto.Response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

import static com.soptie.server.auth.message.ErrorCode.*;
import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

@Component
@RequiredArgsConstructor
public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        setResponse(response);
    }

    private void setResponse(HttpServletResponse response) throws IOException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType(APPLICATION_JSON_VALUE);
        response.setStatus(SC_UNAUTHORIZED);
        response.getWriter().println(objectMapper.writeValueAsString("유효하지 않은 토큰입니다."));
    }
}

위 클래스는 jwt 인증이 실패했을 때 클라이언트에게 "유효하지 않은 토큰입니다."라는 커스텀 메세지를 보내는 역할을 한다.

이제 SecurityConfig를 설정해보자.


16. config 폴더에 SecurityConfig 클래스를 아래와 같이 생성해주자.
import com.chan.lab.jwt.CustomJwtAuthenticationEntryPoint;
import com.chan.lab.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .exceptionHandling(exceptionHandling ->
                        exceptionHandling.authenticationEntryPoint(customJwtAuthenticationEntryPoint))
                .authorizeHttpRequests(authorizeHttpRequests ->
                        authorizeHttpRequests
                                .requestMatchers(new AntPathRequestMatcher("/api/v1/auth/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
                                .anyRequest().authenticated())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

filterChain 메서드의 전반적인 설명은 다음과 같다.

.csrf -> CSRF 보호를 비활성화한다.
.formLogin -> 폼 기반 로그인을 비활성화한다.
.sessionManagement -> 세션 기반 인증을 사용하지 않는다.
.exceptionHandling -> 인증 실패 시 CustomJwtAuthenticationEntryPoint에서 처리한다.
.authorizeHttpRequests -> 설정한 url은 인증 없이 접근 가능하다.
.addFilterBefore -> Http 요청이 UsernamePasswordAuthenticationFilter 전에 JwtAuthenticationFilter를 거치게 한다.

16가지 과정을 거친 덕분에 우리는 소셜 로그인을 구현할 jwt 기본 세팅을 마무리 할 수 있었다.

소셜 로그인을 구현하기 전에 몇 가지 추가로 기본 세팅을 진행해주자.

  1. entity 폴더를 생성한 후, Member 클래스를 아래와 같이 생성해주자.
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor
@Getter
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @Enumerated(value = EnumType.STRING)
    private SocialType socialType;
    
    private String socialId;

    private String refreshToken;
    
    @Builder
    public Member(SocialType socialType, String socialId) {
        this.socialType = socialType;
        this.socialId = socialId;
    }

    public void updateRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public void resetRefreshToken() {
        this.refreshToken = null;
    }
}

socialId는 social Access Token에서 뽑아온 사용자의 정보다.


2. entity 폴더 안에 SocialType Enum을 아래와 같이 생성해주자.
public enum SocialType {
    KAKAO, APPLE
}
  1. repository 폴더를 생성한 후, MemberRepository를 아래와 같이 생성해주자.
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findBySocialTypeAndSocialId(SocialType socialType, String socialId);
}

socialType과 socialId를 통해 Member를 찾아주는 메서드이다.


4. dto 폴더를 생성해준 후, SignInRequest와 SignInResponse를 아래와 같이 각각 생성해주자.

SignInRequest

import lombok.NonNull;

public record SignInRequest(
        @NonNull SocialType socialType
) {

    public static SignInRequest of(SocialType socialType) {
        return new SignInRequest(socialType);
    }
}

SignInResponse

import lombok.Builder;
import lombok.NonNull;

@Builder
public record SignInResponse(
        @NonNull String accessToken,
        @NonNull String refreshToken
) {

    public static SignInResponse of(Token token, boolean isMemberDollExist) {
        return SignInResponse.builder()
                .accessToken(token.getAccessToken())
                .refreshToken(token.getRefreshToken())
                .build();
    }
}

5. vo 폴더를 생성한 후, Token을 아래와 같이 생성해주자.
import lombok.Builder;
import lombok.Getter;

import java.util.Objects;

@Getter
public class Token {

    private final String accessToken;
    private final String refreshToken;

    @Builder
    public Token(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Token token = (Token) o;
        return Objects.equals(accessToken, token.accessToken) && Objects.equals(refreshToken, token.refreshToken);
    }

    @Override
    public int hashCode() {
        return Objects.hash(accessToken, refreshToken);
    }
}

6. service 폴더를 생성한 후, AuthService를 아래와 같이 생성해준다.
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

    private static final int ACCESS_TOKEN_EXPIRATION = 7200000;
    private static final int REFRESH_TOKEN_EXPIRATION = 1209600000;

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;

    @Transactional
    public SignInResponse signIn(String socialAccessToken, SignInRequest request) {
        Member member = getMember(socialAccessToken, request);
        Token token = getToken(member);
        return SignInResponse.of(token);
    }

    @Transactional
    public void signOut(long memberId) {
        Member member = findMember(memberId);
        member.resetRefreshToken();
    }

    @Transactional
    public void withdraw(long memberId) {
        Member member = findMember(memberId);
        deleteMember(member);
    }

    private Member getMember(String socialAccessToken, SignInRequest request) {
        SocialType socialType = request.socialType();
        String socialId = getSocialId(socialAccessToken, socialType);
        return signUp(socialType, socialId);
    }

    private String getSocialId(String socialAccessToken, SocialType socialType) {
        return switch (socialType) {
            case APPLE -> appleService.getAppleData(socialAccessToken);
            case KAKAO -> kakaoService.getKakaoData(socialAccessToken);
        };
    }

    private Member signUp(SocialType socialType, String socialId) {
        return memberRepository.findBySocialTypeAndSocialId(socialType, socialId)
                .orElseGet(() -> saveMember(socialType, socialId));
    }

    private Member saveMember(SocialType socialType, String socialId) {
        Member member = Member.builder()
                .socialType(socialType)
                .socialId(socialId)
                .build();
        return memberRepository.save(member);
    }

    private Token getToken(Member member) {
        Token token = generateToken(new UserAuthentication(member.getId(), null, null));
        member.updateRefreshToken(token.getRefreshToken());
        return token;
    }

    private Token generateToken(Authentication authentication) {
        return Token.builder()
                .accessToken(jwtTokenProvider.generateToken(authentication, ACCESS_TOKEN_EXPIRATION))
                .refreshToken(jwtTokenProvider.generateToken(authentication, REFRESH_TOKEN_EXPIRATION))
                .build();
    }

    private Member findMember(long id) {
        return memberRepository.findById(id)
                .orElseThrow();
    }

    private void deleteMember(Member member) {
        memberRepository.delete(member);
    }
}

AuthService의 메서드의 전반적인 기능은 다음과 같다.

signIn -> 로그인
signOut -> 로그아웃
withdraw -> 탈퇴

getMember -> 클라에서 받은 socialAccessToken을 통해 Apple 서버 또는 Kakao 서버와 통신하여 사용자 정보를 얻어옴
signUp -> 신규 가입자라면 회원 가입

getToken -> 사용자 정보를 통해 refreshToken을 Member에 저장해주고 Token Vo를 가져옴

이제 소셜로그인을 위한 기본적인 세팅은 마쳤다.
바로 소셜 로그인을 구현해보자.

카카오 로그인

  1. 클라에게 카카오 액세스 토큰을 전달받는다.

  2. service 폴더에 KakaoService를 아래와 같이 생성해주자.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.JsonArray;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

@Service
@RequiredArgsConstructor
public class KakaoService {

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    public String getKakaoData(String socialAccessToken) {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", socialAccessToken);
            HttpEntity httpEntity = new HttpEntity<JsonArray>(headers);
            ResponseEntity<Object> responseData = restTemplate.postForEntity("https://kapi.kakao.com/v2/user/me", httpEntity, Object.class);
            return objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString();
        } catch (Exception exception) {
            throw new IllegalStateException();
        }
    }
}

클라에서 받아온 소셜 액세스 토큰을 통해 카카오 서버에 요청을 보내고 사용자 정보를 얻어오는 코드이다.

"Authorization" 헤더에 socialAccessToken을 담고 RestTemplate을 사용하여 카카오 url에 post 요청을 보낸다.

응답 본문을 Map으로 변환하고 "id" 키에 해당하는 값을 반환해준다.

이 값이 Member에 저장될 socialId 값이다.

애플 로그인

  1. 애플은 accessToken이라 하지않고 identityToken이라 한다. 암튼 클라에게 애플 identity Token도 전달받는다.

  2. service 폴더에 AppleService를 아래와 같이 생성해주자.

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Objects;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AppleService {
    
    private JsonArray getApplePublicKeys() {
        HttpURLConnection connection = sendHttpRequest();
        StringBuilder result = getHttpResponse(connection);
        JsonObject keys = (JsonObject) JsonParser.parseString(result.toString());
        return (JsonArray) keys.get("keys");
    }

    private HttpURLConnection sendHttpRequest() {
        try {
            URL url = new URL("https://appleid.apple.com/auth/keys");
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod(HttpMethod.GET.name());
            return connection;
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }
    }

    private StringBuilder getHttpResponse(HttpURLConnection connection) {
        try {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
            return splitHttpResponse(bufferedReader);
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }
    }

    private StringBuilder splitHttpResponse(BufferedReader bufferedReader) {
        try {
            StringBuilder result = new StringBuilder();

            String line;
            while (Objects.nonNull(line = bufferedReader.readLine())) {
                result.append(line);
            }
            bufferedReader.close();

            return result;
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }
    }
}

메서드에 대한 대략적인 설명은 아래와 같다.

getApplePublicKeys -> 애플의 공개 키를 가져온다.
sendHttpRequest -> 애플 서버에 http 요청을 보낸다.
getHttpResponse -> http 응답을 얻어온다.
splitHttpResponse -> 응답을 한 줄씩 잘라준다.

  1. AppleService에 아래 메서드를 마저 추가해주자.
private static final String TOKEN_VALUE_DELIMITER = "\\.";
private static final String MODULUS = "n";
private static final String EXPONENT = "e";
private static final int QUOTES = 1;
private static final int POSITIVE_NUMBER = 1;

private PublicKey makePublicKey(String accessToken, JsonArray publicKeyList) {
    String[] decodeArray = accessToken.split(TOKEN_VALUE_DELIMITER);
    String header = new String(Base64.getDecoder().decode(decodeArray[0].replaceFirst("Bearer ", "")));

    JsonElement kid = ((JsonObject) JsonParser.parseString(header)).get("kid");
    JsonElement alg = ((JsonObject) JsonParser.parseString(header)).get("alg");
    JsonObject matchingPublicKey = findMatchingPublicKey(publicKeyList, kid, alg);

    if (Objects.isNull(matchingPublicKey)) {
        throw new IllegalArgumentException();
    }

    return getPublicKey(matchingPublicKey);
}

private JsonObject findMatchingPublicKey(JsonArray publicKeyList, JsonElement kid, JsonElement alg) {
    for (JsonElement publicKey : publicKeyList) {
        JsonObject publicKeyObject = publicKey.getAsJsonObject();
        JsonElement publicKid = publicKeyObject.get("kid");
        JsonElement publicAlg = publicKeyObject.get("alg");

        if (Objects.equals(kid, publicKid) && Objects.equals(alg, publicAlg)) {
             return publicKeyObject;
        }
    }

    return null;
}

private PublicKey getPublicKey(JsonObject object) {
    try {
        String modulus = object.get(MODULUS).toString();
        String exponent = object.get(EXPONENT).toString();

        byte[] modulusBytes = Base64.getUrlDecoder().decode(modulus.substring(QUOTES, modulus.length() - QUOTES));
        byte[] exponentBytes = Base64.getUrlDecoder().decode(exponent.substring(QUOTES, exponent.length() - QUOTES));

        BigInteger modulusValue = new BigInteger(POSITIVE_NUMBER, modulusBytes);
        BigInteger exponentValue = new BigInteger(POSITIVE_NUMBER, exponentBytes);

        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulusValue, exponentValue);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");

        return keyFactory.generatePublic(publicKeySpec);
    } catch (InvalidKeySpecException | NoSuchAlgorithmException exception) {
        throw new IllegalStateException();
    }
}

메서드에 대한 대략적인 설명은 아래와 같다.

makePublicKey
-> socialAccessToken에서 "Bearer "을 뺀 나머지 부분을 디코딩한다.
-> "kid"와 "alg"에 해당하는 부분을 추출한다.

findMatchingPublicKey
-> 과정 2에서 뽑아온 공개 키 목록에서 일치하는 부분을 가져온다.

getPublicKey
-> JSON 객체에서 MODULUS("n")와 EXPONENT("e") 키에 해당하는 값을 문자열로 추출한다.
-> 인용부호를 제거하고 디코딩한다.
-> 위에서 뽑아 낸 정보를 토대로 RSA 키 객체를 생성한다.
-> RSA 키 객체를 토대로 PublicKey를 생성해낸다.

  1. 마지막으로 아래 메서드를 AppleService에 추가해준다.
public String getAppleData(String socialAccessToken) {
    JsonArray publicKeyList = getApplePublicKeys();
    PublicKey publicKey = makePublicKey(socialAccessToken, publicKeyList);

    Claims userInfo = Jwts.parserBuilder()
            .setSigningKey(publicKey)
            .build()
            .parseClaimsJws(socialAccessToken.replaceFirst("Bearer ", ""))
            .getBody();

    JsonObject userInfoObject = (JsonObject) JsonParser.parseString(new Gson().toJson(userInfo));
    return userInfoObject.get("sub").getAsString();
 }

위 과정에서 뽑아온 JWT에서 사용자 정보를 추출하고 이를 JSON 객체로 변환한 다음 "sub" 필드를 문자열로 추출하여 반환한다.

이를 통해 socialAccessToken에서 사용자의 정보를 뽑아내서 반환해준다.

마지막으로 controller 폴더를 생성한 후 아래와 같이 api를 작성해주자.

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.security.Principal;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthController {

    private final AuthService authService;

    @PostMapping
    public ResponseEntity<> signIn(@RequestHeader("Authorization") String socialAccessToken, @RequestBody SignInRequest request) {
        SignInResponse response = authService.signIn(socialAccessToken, request);
        return ResponseEntity.ok(response);
    }

    @PostMapping("/logout")
    public ResponseEntity<> signOut(Principal principal) {
        long memberId = Long.parseLong(principal.getName());
        authService.signOut(memberId);
        return ResponseEntity.ok(null);
    }

    @DeleteMapping
    public ResponseEntity<> withdrawal(Principal principal) {
        long memberId = Long.parseLong(principal.getName());
        authService.withdraw(memberId);
        return ResponseEntity.ok(null);
    }
}

return 부분은 임의로 수정했으니 본인의 상황에 맞게 반환해주면 된다.

profile
우리가 노래하듯이, 우리가 말하듯이, 우리가 예언하듯이 살길

0개의 댓글