Back-End/SpringBoot

[SpringBoot] 카카오 로그인 구현 (feat. JWT)

zsunny 2024. 11. 18. 21:08

 

카카오 로그인은 여러 방법으로 구현할 수 있는데, 이번 프로젝트에선 카카오에서 제공하는 토큰이 아닌 자체 JWT를 생성하는 방식으로 진행했다. 우선 기본적인 개발 방식은 카카오 공식문서를 참고했다.

 

카카오 로그인 동작 / 구현 순서

1. (프론트) 카카오에게 로그인을 요청하고 인가코드를 받는다.

2. (프론트) 백엔드로 인가코드를 전달한다.

3. (백엔드) redirct-uri, client-id, 인가코드를 카카오에 보내면 카카오는 이를 인증 후 토큰을 발급해준다. 

4. (백엔드) 카카오 토큰으로 유저정보를 요청해 받는다.

5. (백엔드) 기존에 등록된 회원이 아닌 경우 회원가입을 하고, 자체 JWT를 생성하여 프론트에 전달한다.

6. (프론트) JWT로 로그인을 처리한다.

 

카카오 개발자 애플리케이션 설정

https://jk25.tistory.com/179

 

[Spring] Spring Security + 카카오 OAuth2 로그인 구현

카카오 로그인 동작 구현 시연 영상 카카오 OAuth2 로그인 Setting https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api Kakao Developers 카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오

jk25.tistory.com

요 블로그를 참고하여 설정했다.

 

 

JWT dependency 추가

JWT를 사용하기 위해선 dependency를 추가해야하는데, intellij의 add dependency에 올라와있지 않은 관계로 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'

 

 

1. (프론트) 카카오에게 로그인을 요청하고 인가코드를 받는다.

로그인을 요청하면 카카오는 사용자의 동의를 얻고 인가코드를 전달하는 데, 이때 설정한 Redirect URI로 전달된다.

 

2. (프론트) 백엔드로 인가코드를 전달한다.

카카오가 프론트에게 보낸 인가코드를 백엔드가 받는 로직을 작성했다.

 

UserController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {

    private final KakaoService kakaoService;

    // 인가코드 백엔드로 전달 -> 카카오에 토큰 요청
    @GetMapping("/kakao/login")
    public ResponseEntity<LoginResponseDto> kakaoLogin(@RequestParam String code, HttpServletRequest request){
        try{
            LoginResponseDto responseDto = (LoginResponseDto) kakaoService.kakaoLogin(code);
            return ResponseEntity.ok(responseDto);
        }catch (NoSuchElementException e){
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Not Found");
        }
    }
}

 

 

3. (백엔드) redirct-uri, client-id, 인가코드를 카카오에 보내면 카카오는 이를 인증 후  토큰을 발급해준다. 

Body에 각 값을 담아 카카오로 보내며 AccessToken을 요청하는 로직을 작성했다.

 

KakaoService.java

public class KakaoService {

    private final UserRepository userRepository;
    private final AuthTokensGenerator authTokensGenerator;
    @Value("${kakao.client-id}")
    private String clientId;
    @Value("${kakao.redirect-uri}")
    private String redirectUri;

    public Object kakaoLogin(String code) {

        // 1. 인가코드로 엑세스 토큰 요청
        String accessToken = getAccessToken(code);

        // 2. 토큰으로 카카오 API 호출
        HashMap<String, Object> userInfo = getKakaoUserInfo(accessToken);

        // 3. 카카오ID로 회원가입 & 로그인 처리
        LoginResponseDto loginResponseDto = kakaoUserLogin(userInfo);

        return loginResponseDto;

    }

    // 1-1. 인가코드로 엑세스 토큰 요청 메서드
    private String getAccessToken(String code) {

        // 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);

        // kakao에 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
        );
        
        // kakao의 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();
}

 

 

4. (백엔드) 카카오 토큰으로 유저정보를 요청해 받는다.

카카오로부터 받은 AccessToken으로 카카오에 유저정보를 요청해 받는 로직을 작성했다.

 

KakoService.java

@Service
@RequiredArgsConstructor
public class KakaoService {

    private final UserRepository userRepository;
    private final AuthTokensGenerator authTokensGenerator;
    @Value("${kakao.client-id}")
    private String clientId;
    @Value("${kakao.redirect-uri}")
    private String redirectUri;

    public Object kakaoLogin(String code) {

        // 1. 인가코드로 엑세스 토큰 요청
        String accessToken = getAccessToken(code);

        // 2. 토큰으로 카카오 API 호출
        HashMap<String, Object> userInfo = getKakaoUserInfo(accessToken);

        // 3. 카카오ID로 회원가입 & 로그인 처리
        LoginResponseDto loginResponseDto = kakaoUserLogin(userInfo);

        return loginResponseDto;

    }

    // 2-1. 토큰으로 카카오 API (유저 정보) 호출 메서드
    private HashMap<String,Object> getKakaoUserInfo(String accessToken) {
        HashMap<String, Object> userInfo = new HashMap<>();

        // 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
        );

        // response의 body 정보만 꺼냄
        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;
    }

}

 

 

5. (백엔드) 기존에 등록된 회원이 아닌 경우 회원가입을 하고, 자체 JWT를 생성하여 프론트에 전달한다.

카카오로부터 받은 회원 정보를 활용해 기존 회원 여부를 확인하고, 회원가입 및 로그인을 진행하는 로직을 작성했다.

 

KakaoService.java

@Service
@RequiredArgsConstructor
public class KakaoService {

    private final UserRepository userRepository;
    private final AuthTokensGenerator authTokensGenerator;
    @Value("${kakao.client-id}")
    private String clientId;
    @Value("${kakao.redirect-uri}")
    private String redirectUri;

    public Object kakaoLogin(String code) {

        // 1. 인가코드로 엑세스 토큰 요청
        String accessToken = getAccessToken(code);

        // 2. 토큰으로 카카오 API 호출
        HashMap<String, Object> userInfo = getKakaoUserInfo(accessToken);

        // 3. 카카오ID로 회원가입 & 로그인 처리
        LoginResponseDto loginResponseDto = kakaoUserLogin(userInfo);

        return loginResponseDto;

    }

    // 3-1. 카카오ID로 회원가입 & 로그인 처리 메서드
    private LoginResponseDto kakaoUserLogin(HashMap<String, Object> userInfo){
        Long userId = 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 = User.builder()
                    .userId(userId)
                    .nickname(nickname)
                    .email(kakaoEmail)
                    .loginType(LoginType.KAKAO)
                    .build();
            userRepository.save(kakaoUser);
        }

        // 토큰 생성
        AuthTokens token = authTokensGenerator.generate(userId.toString());

        return new LoginResponseDto(userId, kakaoEmail, nickname, token);
    }

}

 

로그인을 위해 프론트에 전달하는 LoginResponseDto는 다음과 같이 작성했다.

 

LoginResponseDto.java

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponseDto {

    private Long id;
    private String email;
    private String nickname;
    private AuthTokens token;

}

 

6. (프론트) JWT로 로그인을 처리한다.

위 코드에서 JWT를 생성하는 로직은 다음과 같다.

 

AuthTokensGenerator.java

@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 * 7;  // 1주일 (7일)

    private final JwtProvider jwtProvider;

    // 사용자 고유 id로 Access Token 생성
    public AuthTokens generate(String userId){
        long now = (new Date()).getTime();
        Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        String accessToken = jwtProvider.accessTokenGenerate(userId, accessTokenExpiredAt);
        String refreshToken = jwtProvider.refreshTokenGenerate(refreshTokenExpiredAt);

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

}

 

accessToken과 refreshToken의 만료시간을 설정하고, 카카오로 부터 받은 사용자 id와 만료시간 그리고 secrete key 값을 사용하여 토큰을 생성한다.

 

JwtProvider.java

@Component
public class JwtProvider {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    private final Key key;

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

    // accessToken 생성
    public String accessTokenGenerate(String subject, Date expiredAt){
        return Jwts.builder()
                .setSubject(subject)        // userId
                .setExpiration(expiredAt)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    //refreshToken 생성
    public String refreshTokenGenerate(Date expiredAt){
        return Jwts.builder()
                .setExpiration(expiredAt)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }
}

 

secrete key 값은 application.yml 또는 application.properties에 작성하면 되는데, HS512 알고리즘 사용을 위해선 64Byte(512bit) 이상을 사용해야 한다. 이 secret key는 OpenSSL 명령어로 다음과 같이 랜덤하게 생성할 수 있다.

(이때, key값은 git과 같은 public repo에 올라가지 않도록 주의)

openssl rand -hex 64

openssl rand -base64 32

 

그리고 이렇게 생성된 token은 BEARER_TYPE과 ACCESS_TOKEN_EXPIRE_TIME과 함께 사용자에게 제공한다.

 

AuthTokens.java

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AuthTokens {

    private String accessToken;
    private String refreshToken;
    private String grantType;
    private Long expires;

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

 


 

참고한 레퍼런스

https://bcp0109.tistory.com/380

https://happy-jjang-a.tistory.com/262

https://velog.io/@jiwoow00/Spring-boot-JWT-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8

https://jules-jc.tistory.com/239

'Back-End > SpringBoot' 카테고리의 다른 글

[SpringBoot] SpringBoot란?  (0) 2023.04.27