Spring 입문 5-1 (Spring OAuth)

SJ.CHO·2024년 10월 29일

소셜 로그인

배경

  • Web Site 들이 점차 늘어나면서 사용자가 모든 사이트에서 회원가입과정을 거치는것에 대한 부담이 늘어나고 보안을위해 사이트 별로 다른 이메일, 비밀번호가 필요.
  • Web Site 또한 회원들의 개인정보를 해커들로부터 보호해야하는 역할이 부담스러울 수 있음.

OAuth

  • 인터넷 사용자들이 비밀번호 제공없이 다른 웹사이트상의 자신들의 정보에대한 접근권한을 부여할 수 있는 공통적 수단 접근 위임 개방형표준 (새로운 회원가입이 아닌 기존 계정에서 APP이 자신의 정보를 확인할수있게 해준다)
  • 사용자가 APP 에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해준다.

카카오 사용자 정보 가져오기

@GetMapping("/user/kakao/callback")
public String kakaoLogin(@RequestParam String code, HttpServletResponse response) throws JsonProcessingException {
    // code: 카카오 서버로부터 받은 인가 코드 Service 전달 후 인증 처리 및 JWT 반환
    String token = kakaoService.kakaoLogin(code);

    // Cookie 생성 및 직접 브라우저에 Set
    Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, token.substring(7));
    cookie.setPath("/");
    response.addCookie(cookie);

    return "redirect:/";
}
  • 로그인 시도시 카카오측에서 보내주는 인가코드를 Controller 수신

@Slf4j(topic = "KAKAO Login")
@Service
@RequiredArgsConstructor
public class KakaoService {

  private final PasswordEncoder passwordEncoder;
  private final UserRepository userRepository;
  private final RestTemplate restTemplate;
  private final JwtUtil jwtUtil;

  public String kakaoLogin(String code) throws JsonProcessingException {
    // 1. "인가 코드"로 "액세스 토큰" 요청
    String accessToken = getToken(code);

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

    return null;
  }
}
  • 인가코드 를 가지고 카카오 로그인처리
  • RestTemplate 또한 Bean으로써 수동등록하면 @RequiredArgsConstructor 를 통해 주입을 받기가 가능하다
@Configuration
public class RestTemplateConfig  {
    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                // RestTemplate 으로 외부 API 호출 시 일정 시간이 지나도 응답이 없을 때
                // 무한 대기 상태 방지를 위해 강제 종료 설정
                .setConnectTimeout(Duration.ofSeconds(5)) // 5초
                .setReadTimeout(Duration.ofSeconds(5)) // 5초
                .build();
    }
}
  • RestTemplate 으로 외부 API 호출 시 일정 시간이 지나도 응답이 없을 때 무한 대기 상태 방지를 위해 강제 종료 설정이 가능하다.
KaKaoService

private String getToken(String code) throws JsonProcessingException {
    // 요청 URL 만들기
    URI uri = UriComponentsBuilder
            .fromUriString("https://kauth.kakao.com")
            .path("/oauth/token")
            .encode()
            .build()
            .toUri();

    // 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", "본인의 REST API키");
    body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
    body.add("code", code);

    RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
            .post(uri)
            .headers(headers)
            .body(body);

    // HTTP 요청 보내기
    ResponseEntity<String> response = restTemplate.exchange(
            requestEntity,
            String.class
    );

    // HTTP 응답 (JSON) -> 액세스 토큰 파싱
    JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
    return jsonNode.get("access_token").asText();
}
  • 카카오에게 받은 인가코드를 이용해 액세스 토큰 요청

  • 해당형태의 액세스토큰을 받아온다.
private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
    // 요청 URL 만들기
    URI uri = UriComponentsBuilder
            .fromUriString("https://kapi.kakao.com")
            .path("/v2/user/me")
            .encode()
            .build()
            .toUri();

    // HTTP Header 생성
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + accessToken);
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
            .post(uri)
            .headers(headers)
            .body(new LinkedMultiValueMap<>());

    // HTTP 요청 보내기
    ResponseEntity<String> response = restTemplate.exchange(
            requestEntity,
            String.class
    );

    JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
    Long id = jsonNode.get("id").asLong();
    String nickname = jsonNode.get("properties")
            .get("nickname").asText();
    String email = jsonNode.get("kakao_account")
            .get("email").asText();

    log.info("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
    return new KakaoUserInfoDto(id, nickname, email);
}
  • 받아온 토큰을 가지고 로그인 유저에 대한 정보를 요청하는 콜백 API
{
  "id": 1632335751,
  "properties": {
    "nickname": "르탄이",
    "profile_image": "http://k.kakaocdn.net/...jpg",
    "thumbnail_image": "http://k.kakaocdn.net/...jpg"
  },
  "kakao_account": {
    "profile_needs_agreement": false,
    "profile": {
      "nickname": "르탄이",
      "thumbnail_image_url": "http://k.kakaocdn.net/...jpg",
      "profile_image_url": "http://k.kakaocdn.net/...jpg"
    },
    "has_email": true,
    "email_needs_agreement": false,
    "is_email_valid": true,
    "is_email_verified": true,
    "email": "letan@sparta.com"
  }
}
  • 해당형태의 JSON 을 카카오 사용자의 정보를 받아온다.
  • 이후에는 해당 사이트에서 JWT를 발급해주는 처리가 필요.
    • 첫 로그인시 회원가입 이후 JWT 발급, 이미 회원이라면 JWT 발급 처리를 완료한다면 카카오로그인 기능구현 완료.

카카오 사용자정보로 회원가입.

  • 테이블 설계 옵션
  1. 카카오 User 를 위한 테이블 (ex. KakaoUser) 을 하나 더 만든다.
    • 결합도의 하락, JSON 내부의 정보들을 더많이 처리가능 Ex) 프로필이미지
    • 구현난이도 증가 -> 일반유저, 카카오유저의 따른 별도의 처리가 필요
  2. 기존 회원 (User) 테이블에 카카오 User 추가
    • 구현난이도가 낮다.
    • 결합도가 높아진다. -> 기존의 로그인에서 카카오유저가 로그인을 진행한다면?

  • 패스워드를 UUID 를 사용하는 이유는 카카오유저가 폼로그인의 로그인을 막기위해서.

UUID(Universally Unique Identifier) : 128bit로 이루어진 유저 고유식별자 1조 개의 UUID 중에 중복이 일어날 확률은 10억 분의 1확률

자바 사용법.
UUID.randomUUID()
public String kakaoLogin(String code, HttpServletResponse response) throws JsonProcessingException {
    // 1. "인가 코드"로 "액세스 토큰" 요청
    String accessToken = getToken(code);

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

    // 3. 필요시에 회원가입
    User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

    // 4. JWT 토큰 반환
    String createToken =  jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());

    return createToken;
}
  • 필요시에는 회원가입에 대해 UserRepository 를 사용하여 해당 유저를 저장
  • 기존유저라면 KWT 토큰을 바로 반환하는 처리.
private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
    // DB 에 중복된 Kakao Id 가 있는지 확인
    Long kakaoId = kakaoUserInfo.getId();
    User kakaoUser = userRepository.findByKakaoId(kakaoId).orElse(null);

    if (kakaoUser == null) {
        // 카카오 사용자 email 동일한 email 가진 회원이 있는지 확인
        String kakaoEmail = kakaoUserInfo.getEmail();
        User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
        if (sameEmailUser != null) {
            kakaoUser = sameEmailUser;
            // 기존 회원정보에 카카오 Id 추가
            kakaoUser = kakaoUser.kakaoIdUpdate(kakaoId);
        } else {
            // 신규 회원가입
            // password: random UUID
            String password = UUID.randomUUID().toString();
            String encodedPassword = passwordEncoder.encode(password);

            // email: kakao email
            String email = kakaoUserInfo.getEmail();

            kakaoUser = new User(kakaoUserInfo.getNickname(), encodedPassword, email, UserRoleEnum.USER, kakaoId);
        }

        userRepository.save(kakaoUser);
    }
    return kakaoUser;
}
  • User Table의 변화가 생기면서 KakaoId를 통해서 유저정보를 받아오기가 가능해짐. 이를 통해 카카오로그인을 통해 한벙이라도 접근을해봤는지에 대한 판단이 가능.

  • 카카오로그인을 통해서 동일 Email 이 이미 DB에 존재한다면 기존유저가 카카오 로그인을 시도한것으로 판단.
    기존회원 정보에 카카오 ID를 업데이트를 수행한다. 이후 해당유저는 2가지 로그인방법 모두 시도가 가능하다.

  • @Transactional 이 따로 없는 이유는 업데이트와 생성이 섞여있는 메소드기에 명시적으로 userRepository.save(kakaoUser); 를 해주기 때문. save 메소드 내부에서 자동적으로 저장 혹은 객체비교후 업데이트를 해주기에 더티체킹을 사용하지 않아도 업데이트가 가능하다.

요약

  1. 개발자의 App에게 Kakao API의 접근할 수 있는 REST API Key 를 전달.
  2. Kakao API 에서는 접근 해당 서버가 접근할수 있도록 인가코드 를 전달
  3. 인가코드 를 통해서 해당 유저가 Kakao로 로그인하였다는 액세스 토큰 을 수신
  4. 액세스 토큰 을 통하여 어느 유저가 로그인 했는지에 대한 정보를 Kakao 서버에게 요청 및 수신.
  5. 해당 사용자정보를 가지고 회원가입 혹은 로그인처리 진행.
profile
70살까지 개발하고싶은 개발자

0개의 댓글