Spring Boot + 카카오 로그인

형석이의 성장일기·2023년 10월 9일
0

이제 Spring Boot에서 카카오 로그인을 구현할 때, 필요한 코드를 만들어보자.

디렉토리 구조는 이렇다.

전체적인 흐름은 이렇다.

코드만 많지.. 흐름은 되게 간단함!

mt/authentication/application/AuthController.java

package com.project.mt.authentication.application;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.project.mt.authentication.infra.kakao.KakaoLoginParams;

import lombok.RequiredArgsConstructor;


/**
 * 카카오 서버에서 받은 인증코드를 가지고 들어오는 Controller
 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@CrossOrigin(origins = "*")
public class AuthController {

    private final OAuthLoginService oAuthLoginService;

    /**
     * @param code : 소셜 서비스, 인가코드를 가지고있는 파라미터
     * @return
     */
    @GetMapping("/kakao")
    public ResponseEntity<?> loginKakao(@RequestParam("code") String code) {
        KakaoLoginParams params = new KakaoLoginParams(code);
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }

}

mt/authentication/application/OAuthLoginService.java

package com.project.mt.authentication.application;

import org.springframework.stereotype.Service;

import com.project.mt.authentication.domain.AuthTokensGenerator;
import com.project.mt.authentication.domain.oauth.OAuthInfoResponse;
import com.project.mt.authentication.domain.oauth.OAuthLoginParams;
import com.project.mt.authentication.domain.oauth.RequestOAuthInfoService;
import com.project.mt.authentication.dto.AuthResponseDto;
import com.project.mt.member.domain.Member;
import com.project.mt.member.repository.MemberRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class OAuthLoginService {

    private final MemberRepository memberRepository;
    private final AuthTokensGenerator authTokensGenerator;

    // 각 서비스 소셜의 서버에 accessToken, refreshToken 요청을 담당
    private final RequestOAuthInfoService requestOAuthInfoService;

    public AuthResponseDto login(OAuthLoginParams params) {
        OAuthInfoResponse oAuthInfoResponse = requestOAuthInfoService.request(params);
        Long memberIdx = findOrCreateMember(oAuthInfoResponse);
        return new AuthResponseDto(memberIdx, authTokensGenerator.generate(memberIdx).getAccessToken());
    }

    /**
     * DB에 oAuthInfoResponse 와 일치하는 유저 정보가
     * 이미 있으면 유저의 고유 idx 를 가져오고,
     * 없으면 DB에 유저를 저장
     * @param oAuthInfoResponse : DB에 있는지 확인할 유저의 정보
     * @return
     */
    private Long findOrCreateMember(OAuthInfoResponse oAuthInfoResponse) {
        System.out.println(oAuthInfoResponse.getOAuthProvider());
        return memberRepository.findMemberByEmail(oAuthInfoResponse.getEmail())
                .map(Member::getMemberIdx)
                .orElseGet(() -> newMember(oAuthInfoResponse));
    }

    private Long newMember(OAuthInfoResponse oAuthInfoResponse) {
        Member member = Member.builder()
                .email(oAuthInfoResponse.getEmail())
                .name(oAuthInfoResponse.getNickname())
                .oAuthProvider(oAuthInfoResponse.getOAuthProvider())
                .build();

        return memberRepository.save(member).getMemberIdx();
    }
}

mt/authentication/config/ClientConfig.java

package com.project.mt.authentication.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class ClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

mt/authentication/domain/oauth/OAuthApiClient.interface

package com.project.mt.authentication.domain.oauth;

public interface OAuthApiClient {
    OAuthProvider oAuthProvider();
    String requestAccessToken(OAuthLoginParams params);
    OAuthInfoResponse requestOauthInfo(String accessToken);
}

mt/authentication/domain/oauth/OAuthInfoResponse.interface

package com.project.mt.authentication.domain.oauth;

public interface OAuthInfoResponse {
    String getEmail();
    String getNickname();
    OAuthProvider getOAuthProvider();
}

mt/authentication/domain/oauth/OAuthLoginParams.interface

package com.project.mt.authentication.domain.oauth;

import org.springframework.util.MultiValueMap;

public interface OAuthLoginParams {
    OAuthProvider oAuthProvider();
    MultiValueMap<String, String> makeBody();
}

mt/authentication/domain/oauth/OAuthProvider.enum

package com.project.mt.authentication.domain.oauth;

public enum OAuthProvider {
    KAKAO
}

mt/authentication/domain/oauth/RequestOAuthInfoService.java

package com.project.mt.authentication.domain.oauth;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.springframework.stereotype.Component;

@Component
public class RequestOAuthInfoService {
    private final Map<OAuthProvider, OAuthApiClient> clients;

    public RequestOAuthInfoService(List<OAuthApiClient> clients) {
        this.clients = clients.stream().collect(
                Collectors.toUnmodifiableMap(OAuthApiClient::oAuthProvider, Function.identity())
        );
    }

    /**
     * 각 소셜 서비스의 서버에 AccessToken 을 요청
     */
    public OAuthInfoResponse request(OAuthLoginParams params) {
        OAuthApiClient client = clients.get(params.oAuthProvider());
        String accessToken = client.requestAccessToken(params);
        return client.requestOauthInfo(accessToken);
    }
}

mt/authentication/domain/AuthTokens.java

package com.project.mt.authentication.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthTokens {
    private String accessToken;
    private String refreshToken;
    private String grantType;
    private Long expiresIn;

    public static AuthTokens of(String accessToken, String refreshToken, String grantType, Long expiresIn) {
        return new AuthTokens(accessToken, refreshToken, grantType, expiresIn);
    }
}

mt/authentication/domain/AuthTokensGenerator.java

package com.project.mt.authentication.domain;

import java.util.Date;

import com.project.mt.member.domain.Member;
import com.project.mt.member.repository.MemberRepository;
import org.springframework.stereotype.Component;

import com.project.mt.authentication.infra.JwtTokenProvider;

import lombok.RequiredArgsConstructor;


/**
 * Auth 토큰 생성기
 */
@Component
@RequiredArgsConstructor
public class AuthTokensGenerator {
    private static final String BEARER_TYPE = "Bearer";
//    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 10;            // 10분
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 10;   // 10일
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 20;  // 20일

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthTokens generate(Long memberIdx) {
        String subject = memberIdx.toString();

        // accessToken 과 refreshToken 생성
        String accessToken = generateAccessToken(subject);
        String refreshToken = generateRefreshToken(subject);

        memberRepository.saveRefreshToken(refreshToken, memberIdx);

        return AuthTokens.of(accessToken, refreshToken, BEARER_TYPE, ACCESS_TOKEN_EXPIRE_TIME / 1000L);
    }

    public String generateAccessToken(String subject) {
        long now = (new Date()).getTime();

        Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);

        return jwtTokenProvider.generate(subject, accessTokenExpiredAt);
    }

    public String generateRefreshToken(String subject) {
        long now = (new Date()).getTime();
        Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        return jwtTokenProvider.generate(subject, refreshTokenExpiredAt);
    }

    public Long extractMemberId(String accessToken) {
        return Long.valueOf(jwtTokenProvider.extractSubject(accessToken));
    }
}

mt/authentication/dto/AuthResponseDto.java

package com.project.mt.authentication.dto;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class AuthResponseDto {
	private Long memberIdx;
	private String accessToken;
}

mt/authentication/infra/kakao/KakaoApiClient.java

package com.project.mt.authentication.infra.kakao;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import com.project.mt.authentication.domain.oauth.OAuthApiClient;
import com.project.mt.authentication.domain.oauth.OAuthInfoResponse;
import com.project.mt.authentication.domain.oauth.OAuthLoginParams;
import com.project.mt.authentication.domain.oauth.OAuthProvider;

import lombok.RequiredArgsConstructor;

/**
 * 카카오 서버(인가 서버, 리소스 서버)에 요청을 보낼 Class
 */
@Component
@RequiredArgsConstructor
public class KakaoApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = "authorization_code";

    @Value("${oauth.kakao.url.auth}")
    private String authUrl;

    @Value("${oauth.kakao.url.api}")
    private String apiUrl;

    @Value("${oauth.kakao.client-id}")
    private String clientId;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    /**
     * 카카오 클라이언트 서버에 인가코드를 사용해 요청을 보내 accessToken 을 발급받음
     */
    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + "/oauth/token";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = params.makeBody();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", clientId);

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        KakaoTokens response = restTemplate.postForObject(url, request, KakaoTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    /**
     * accessToken 을 사용해 카카오 클라이언트 서버에 유저 정보를 요청함
     */
    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + "/v2/user/me";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set("Authorization", "Bearer " + accessToken);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("property_keys", "[\"kakao_account.email\", \"kakao_account.profile\"]");

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        return restTemplate.postForObject(url, request, KakaoInfoResponse.class);
    }
}

mt/authentication/infra/kakao/KakaoInfoResponse.java

package com.project.mt.authentication.infra.kakao;


import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.project.mt.authentication.domain.oauth.OAuthInfoResponse;
import com.project.mt.authentication.domain.oauth.OAuthProvider;

import lombok.Getter;

@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoInfoResponse implements OAuthInfoResponse {

    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoAccount {
        private KakaoProfile profile;
        private String email;
    }

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoProfile {
        private String nickname;
    }

    @Override
    public String getEmail() {
        return kakaoAccount.email;
    }

    @Override
    public String getNickname() {
        return kakaoAccount.profile.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.KAKAO;
    }
}

mt/authentication/infra/kakao/KakaoLoginParams.java

package com.project.mt.authentication.infra.kakao;

import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import com.project.mt.authentication.domain.oauth.OAuthLoginParams;
import com.project.mt.authentication.domain.oauth.OAuthProvider;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class KakaoLoginParams implements OAuthLoginParams {
    private String authorizationCode;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        return body;
    }
}

mt/authentication/infra/kakao/KakaoTokens.java

package com.project.mt.authentication.infra.kakao;

import com.fasterxml.jackson.annotation.JsonProperty;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class KakaoTokens {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("expires_in")
    private String expiresIn;

    @JsonProperty("refresh_token_expires_in")
    private String refreshTokenExpiresIn;

    @JsonProperty("scope")
    private String scope;
}

mt/authentication/infra/JwtTokenProvider.java

package com.project.mt.authentication.infra;

import java.security.Key;
import java.util.Date;

import com.project.mt.exception.ErrorCode;
import com.project.mt.exception.RestApiException;
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;

@Component
public class JwtTokenProvider {

    private Key key;

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

    public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     *
     * @param subject : memberIdx
     * @param expiredAt : 만료일자
     * @return
     */
    public String generate(String subject, Date expiredAt) {
        return Jwts.builder()
                .setSubject(subject)
                .setExpiration(expiredAt)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    /**
     * accessToken 에서 memberIdx 를 추출함
     */
    public String extractSubject(String accessToken) {
        Claims claims = parseClaims(accessToken);
        return claims.getSubject();
    }

    public boolean vaildAccessToken(String accessToken) {
        try {
            Claims claims =  Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(accessToken)
                    .getBody();
            return true;  //유효하다면 true 반환
        } catch (MalformedJwtException e) {
            return false;
        } catch (ExpiredJwtException e) {
            return false;
        }
    }

    public boolean vaildRefreshToken(String refreshToken) {
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(refreshToken)
                    .getBody();
            return true;  //유효하다면 true 반환
        } catch (MalformedJwtException e) {
            throw new RestApiException(ErrorCode.UNAUTHORIZED_REQUEST);
        } catch (ExpiredJwtException e) {
            throw new RestApiException(ErrorCode.VALID_TOKEN_EXPIRED);
        }
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

필요한 변수만 넣어놨습니다!

resources/application.properties


...

# jwt
JWT_SECRET_KEY=

# Social Login
KAKAO_CLIENT_ID=

...
profile
이사중 .. -> https://gudtjr2949.tistory.com/

0개의 댓글