프로젝트를 하며 노션에 끄적끄적 작성했었는데 공부할 겸 다시 정리해보기 위해 블로그를 작성해본다. (로그인 부분만 일단 캡쳐... ㅎ)
처음에는 블로그 참고자료가 많아서 쉽게 구현할 수 있을 것이라고 생각했는데 오산이었다... 나는 스프링부트를 이용하는 것도 처음이고 jwt 토큰을 사용하는 것도 처음이어서 코드를 이해하는 것 부터 쉽지 않았다. 그리고 무엇보다 구현 방식이 너무나도 달랐기에 어떤 구조를 선택해야할지부터 막막했다.
jwt 토큰은 서버리스라는 특징이 있기 때문에 따로 토큰을 db에 저장하고 꺼내오지 않고, 오직 토큰을 가지고 유저 정보를 얻을 수 있다.
그럼 로그아웃, 토큰 재발급과 관련해서 어떤식으로 처리할 것인가..? 에 대해 또 고민에 빠졌는데 이 부분까지 공부하고 결정하려면 도저히 시급한 로그인 구현이 늦어질 것 같았기 때문에 나중에 구현하기로 했다.
우선은!!
https://jules-jc.tistory.com/239
이 블로그를 보고 카카오로그인 구현방식에 대해 체계를 잡았고 이 방식대로 구현했다.
순서요약
1. (프론트) 카카오로부터 인가코드를 받아온다.
2. (프론트) 인가코드를 (백엔드)로 전달한다.
3. (백엔드) 받은 인가코드로 카카오에 토큰을 요청한다.
4. 카카오는 redirect uri, clientid, 인가코드를 검증 후 회원 정보가 담긴 카카오 토큰을 전송한다.
5. (백엔드) 카카오 토큰으로 유저정보를 get 한 후, 유저를 등록한다.
6. (백엔드) 카카오에서 제공하는 refresh토큰이 아닌, 우리 플젝 서버에서 제공하는 자체 JWT Token을 생성하여 (프론트)에 전송한다.
7. (프론트) JWT Token으로 로그인을 처리한다.
이 단계대로 구현하고자 한다.
은 다른 블로그들 많으니 난 간략하게만 적겠다.
JWT 를 이용하기 위해 build.gradle에 아래코드를 추가했고
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
application.properties
에 아래와 같이 kakao developers에서 얻었던 client id(=REST API 키)와 설정했던 redirect uri를 추가한다.
#key
kakao.key.client-id=4xxxxxxxxxxxxxxxxxxxxxx
kakao.redirect-uri=http://localhost:3000/api/users/login/oauth/kakao
먼저, 인가코드란?
일단, 아래의 url에 접속해보자.
https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}
혹시나 나처럼 ${REST_API_KEY} 이 부분에 { }안에 key를 넣어야 하는건가? 헷갈리는 사람을 위해 실제 테스트로 사용했던 uri를 넣어봤다. (REST_API_KEY와 REDIRECT_URI는 맞게 수정해주세요!)
https://kauth.kakao.com/oauth/authorize?client_id=4xxxxxxxxxxxxxxxx&redirect_uri=http://localhost:8080/api/users/login/oauth/kakao&response_type=code
(참고로 저는 먼저 백엔드 로컬에서 테스트를 하기 위해 redirect_uri를 http://localhost:8080/api/users/login/oauth/kakao 로 작성했다. 그러나 이 redirect URI는 프론트에서 접근할 수 있는 Host로 지정해야한다. 프론트 로컬환경(localhost:3000), 실제 배포환경에서도 접속해야하기 때문에 redirect_uri를 어떻게 설정해야하는지 몰랐어서 정말 헤맸다..)
어쨌든
https://kauth.kakao.com/oauth/authorize? ~~
이 주소로 접근하게 되면 아래와 같이 화면이 뜨는 것을 확인할 수 있다.
동의하기를 누른다면 이동되는 화면이 Redirect URI 화면이다.
이때, 프론트는 code={인가코드} 에서 인가코드 String을 백엔드로 넘겨주면 된다.
(참고로 난 백엔드 localhost:8080에서 test할때 code 뒤의 인가코드를 복사해서 여기에 넣어주어 제대로 로그인 되는지 test했다.)
아무튼!!! 이제 본격적으로 코드를 살펴보자.
백엔드에서 프론트로부터 인가코드를 받는 코드를 작성해본다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
@Api(tags = {"유저 API"})
public class UserController {
private final KakaoService kakaoService;
private final AppleService appleService;
//web 버전
@ResponseBody
@GetMapping("/login/oauth/kakao")
@ApiOperation(value = "웹 카카오 로그인", notes = "웹 프론트 버전 카카오 로그인")
public ResponseEntity<LoginResponse> kakaoLogin(@RequestParam String code, HttpServletRequest request){
try{
// 현재 도메인 확인
String currentDomain = request.getServerName();
return ResponseEntity.ok(kakaoService.kakaoLogin(code, currentDomain));
} catch (NoSuchElementException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Item Not Found");
}
}
kakaoLogin(@RequestParam String code)
를 보면 RequestParam을 통해 인가코드를 받고 있다. 참고로 Post가 아닌 GetMapping
을 해야한다. 그리고 카카오developer 설정에서 작성했던 redirect uri 대로 mapping 한다.
(참고로, currentDomain은 프론트측에서 local환경인지 배포환경인지에 따라 redirect uri를 다르게 설정해야했기 때문에 추가한 것이니 빼도 된다.)
@Service
@RequiredArgsConstructor
public class KakaoService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final UserRepository userRepository;
private final AuthTokensGenerator authTokensGenerator;
private final JwtTokenProvider jwtTokenProvider;
@Value("${kakao.key.client-id}")
private String clientId;
@Value("${kakao.redirect-uri}")
private String redirectUri;
기본 세팅을 해주고 이제 카카오로그인을 구현해보자.
카카오 로그인을 한 다음에 LoginResponse를 프론트에 보낼 것이기 때문에 먼저 LoginResponse DTO를 작성한다.
@Getter
@NoArgsConstructor
public class LoginResponse {
private Long id;
private String nickname;
private String email;
private AuthTokens token;
public LoginResponse(Long id, String nickname, String email, AuthTokens token) {
this.id = id;
this.nickname = nickname;
this.email = email;
this.token = token;
}
}
LoginResponse에서 accessToken, refreshToken,타입,만료시간의 정보를 담는 AuthTokens
를 만들었다.
https://bcp0109.tistory.com/380 토큰 관련 Auth 파일은 이 블로그를 많이 참고했다.
@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);
}
}
이제 다시 kakaoService.java 파일로 돌아와서 카카오 로그인 코드를 작성해보자.
/** Web 버전 카카오 로그인 **/
public LoginResponse kakaoLogin(String code, String currentDomain) {
//0. 동적으로 redirect URI 선택
String redirectUri=selectRedirectUri(currentDomain);
// 1. "인가 코드"로 "액세스 토큰" 요청
String accessToken = getAccessToken(code, redirectUri);
// 2. 토큰으로 카카오 API 호출
HashMap<String, Object> userInfo= getKakaoUserInfo(accessToken);
//3. 카카오ID로 회원가입 & 로그인 처리
LoginResponse kakaoUserResponse= kakaoUserLogin(userInfo);
return kakaoUserResponse;
}
3번째 단계에서 프론트에서 받은 인가코드
를 가지고 카카오에 요청을 해야하기 때문에 아래와 같이 작성했으며, 카카오에서 전달받은 access token을 return한다.
//1. "인가 코드"로 "액세스 토큰" 요청
private String getAccessToken(String code, String redirectUri) {
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP Body 생성
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", redirectUri);
body.add("code", code);
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(body, headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
kakaoTokenRequest,
String.class
);
// HTTP 응답 (JSON) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = null;
try {
jsonNode = objectMapper.readTree(responseBody);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return jsonNode.get("access_token").asText(); //토큰 전송
}
앞 단계에서 String getAccessToken
함수를 통해 얻은 액세스토큰을 바탕으로 유저 정보를 가져온다.
//2. 토큰으로 카카오 API 호출
private HashMap<String, Object> getKakaoUserInfo(String accessToken) {
HashMap<String, Object> userInfo= new HashMap<String,Object>();
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoUserInfoRequest,
String.class
);
// responseBody에 있는 정보를 꺼냄
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = null;
try {
jsonNode = objectMapper.readTree(responseBody);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
Long id = jsonNode.get("id").asLong();
String email = jsonNode.get("kakao_account").get("email").asText();
String nickname = jsonNode.get("properties").get("nickname").asText();
userInfo.put("id",id);
userInfo.put("email",email);
userInfo.put("nickname",nickname);
return userInfo;
}
그 다음 카카오ID로 회원가입 및 로그인을 시도한다.
//3. 카카오ID로 회원가입 & 로그인 처리
private LoginResponse kakaoUserLogin(HashMap<String, Object> userInfo){
Long uid= Long.valueOf(userInfo.get("id").toString());
String kakaoEmail = userInfo.get("email").toString();
String nickName = userInfo.get("nickname").toString();
User kakaoUser = userRepository.findByEmail(kakaoEmail).orElse(null);
if (kakaoUser == null) { //회원가입
kakaoUser= new User();
kakaoUser.setUid(uid);
kakaoUser.setNickname(nickName);
kakaoUser.setEmail(kakaoEmail);
kakaoUser.setLoginType("kakao");
userRepository.save(kakaoUser);
}
//토큰 생성
AuthTokens token=authTokensGenerator.generate(uid.toString());
return new LoginResponse(uid,nickName,kakaoEmail,token);
}
카카오에서도 토큰을 발급해주지만, 서버 자체 jwt 토큰을 생성해서 주고 받는게 보안상 안정하기 때문에 jwt 토큰을 생성해보자.
@Component
@RequiredArgsConstructor
public class AuthTokensGenerator {
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 //1시간
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; // 14일
private final JwtTokenProvider jwtTokenProvider;
//id 받아 Access Token 생성
public AuthTokens generate(String uid) {
long now = (new Date()).getTime();
Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
//String subject = email.toString();
String accessToken = jwtTokenProvider.accessTokenGenerate(uid, accessTokenExpiredAt);
String refreshToken = jwtTokenProvider.refreshTokenGenerate(refreshTokenExpiredAt);
return AuthTokens.of(accessToken, refreshToken, BEARER_TYPE, ACCESS_TOKEN_EXPIRE_TIME / 1000L);
}
}
accessToken과 refreshToken의 만료시간은 각각 1시간과 2주로 설정했다. (아직은 accessToken이 만료되었을 때 refreshToken을 가지고 재발급 하는 것은 구현하지 못했지만 추후 구현할 예정이다.)
return 값으로는 토큰정보를 담은 AuthTokens 객체를 넘겨준다.
@Component
public class JwtTokenProvider {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final Key key;
public JwtTokenProvider(@Value("${custom.jwt.secretKey}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String accessTokenGenerate(String subject, Date expiredAt) {
return Jwts.builder()
.setSubject(subject) //uid
.setExpiration(expiredAt)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
public String refreshTokenGenerate(Date expiredAt) {
return Jwts.builder()
.setExpiration(expiredAt)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
여기서 secretKey는 application.properties
파일에서 설정한다.
custom.jwt.secretKey=비밀키값넣기(아무거나 적어도 됩니다.)
application.properties에 적은 특정 문자열 secretKey을 가지고 Base64 로 인코딩한 값인 secretKey가 만들어진다.
참고로, 토큰을 생성할때 HS512 알고리즘을 사용을 위해secret key는 64B(512bit) 이상을 사용해야 한다. 나는 짧게 만들었다가 오류가 떴다.
이제, accessTokenGenerate
함수를 살펴보면 AuthTokensGenerator.java
에서 넘겨줬던 subject과 만료시간을 인자로 받고 있다.
public String accessTokenGenerate(String subject, Date expiredAt) {
return Jwts.builder()
.setSubject(subject) //uid
.setExpiration(expiredAt)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
setSubject를 통해 jwt 토큰에 uid를 세팅하고, 만료시간과 key를 설정하여 return한다. 이 부분은 마지막 단락에 jwt가 어떻게 구성되는지 간략하게 작성해봤다.
그리고 토큰이 탈취될 위험이 있기 때문에 만료시간이 상대적으로 긴 refreshToken에는 유저정보(uid)를 담지 않았고 accessToken에만 uid를 넣어서 로그인이 필요한 서비스에 (프론트는) accessToken을 담아서 서버에서 인증/인가를 한다.
이 값을 프론트에 넘겨주면 프론트는 storage에 저장하여 사용한다. 캡쳐사진에는 localhost:8000이지만, 프론트측의 localhost:3000으로 바꿔줘야 하고, 배포를 한 상태라면 배포url로 변경해야한다.
그리고 이때 프론트와 통신하며 server 500
에러가 발생하는 경우가 많다... 백엔드 로컬에서는 한번에 성공했어가지고 정말 뭐가 문젠가 싶었다.
우리 프로젝트 같은 경우에는, 프론트 측에서 인가코드를 [새로고침]하게 돼서 2번 요청해서 발생했던 오류였다. 인가코드는 로그인 요청을 할 때마다 매번 변경되므로, 로그인이 완료되기 전까지 새롭게 요청을 하면 안된다. 이걸 어떻게 발견하게 됐냐면 프론트 측에서 안되는 인가코드를 가지고 swagger에 계속 시도했더니 500 에러만 발생했다. (당연함. 인가코드는 1번만 쓸 수 있음..) 그런데 프론트 개발자 분이 새롭게 발급한 인가코드로 시도해봤더니 성공했다! 그리고 다시 새로고침하고 그 인가코드를 다시 썼더니 500에러가 떴다. 그래서 인가코드를 한번 요청했으면 다시 요청하면 안된다는 것을 알았고 프론트 측에서 새로고침하는 코드가 잘못 들어갔다는 것을 알게되었다.
아무튼...!!! 다행히도 web 버전 카카오로그인 성공😊
(왜 web버전이냐면 ios버전 카카오로그인은 또 로직이 다르기 때문...ㅎ 이것도 나중에 포스팅 해보겠다.)
아까 토큰을 생성할때
.setSubject(subject) //uid
.setExpiration(expiredAt)
uid와 만료시간을 설정했었는데 이 부분이 jwt의 Payload에 세팅되었다. 그래서 프론트에서 로그인이 필요한 서비스 (ex.북마크)를 이용할 때에는 header에 accessToken을 담아 API를 호출하게 되면 백엔드 서버에서 jwt를 decode하여 유효성을 검증한 후, 사용자의 uid를 얻을 수 있게 된다.
이 부분은 다음 포스팅에서 다뤄보겠다.
정말 많이 도움 받은 레퍼런스
https://jules-jc.tistory.com/239
https://bcp0109.tistory.com/380
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
안녕하세요 글 잘 읽었습니다 생략된 코드가 좀 있는거같아서 이해가 완벽히 되지않는데 혹시 깃허브링크를 받을 수 있을까요?