이제 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=
...