본문 바로가기

Backend

[Spring Security] 카카오 Auth2.0 기반 인증인가 프로세스 적용

반응형

 

처음으로 Auth 서비스를 활용해보았다. 카카오 developer 사이트를 참고해서 앱을 등록하고 사용자 정보를 가져오는데 성공했다. Document를 읽으며 api-key와 secret-key의 활용점을 파악하고, 진행 프로세스를 익히며 프론트팀과 협업을 진행하였고, 프론트팀에서 인가코드만 제공해주고 카카오 액세스토큰 발급과 사용자 정보 요청 단계는 백엔드에서 하기로 결정을 했다. 

 

공개되어서는 안되는 정보는 yml 파일에 저장해두었고, 나머지 전체 코드는 여기에 올려두고자 한다.

먼저 프론트로부터 인가코드를 받게 되면 카카오 서버로 액세스토큰을 요청한다.

 

public String getKakaoAccessToken(String code) throws JsonProcessingException {
    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", kakaoConfig.getGrant_type());
    body.add("client_id", kakaoConfig.getClientId());
    body.add("redirect_uri", kakaoConfig.getRedirect_uri());
    body.add("code", code);
    body.add("client_secret", kakaoConfig.getClient_secretId());

    // HTTP 요청 보내기
    HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(body, headers);
    RestTemplate rt = new RestTemplate();
    ResponseEntity<String> response = rt.exchange(
            kakaoConfig.getAccessTokenRequestUrl(),
            HttpMethod.POST,
            kakaoTokenRequest,
            String.class
    );
    // HTTP 응답 (JSON) -> 액세스 토큰 파싱
    String responseBody = response.getBody();
    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode jsonNode = objectMapper.readTree(responseBody);
    return jsonNode.get("access_token").asText();
}

 

수신한 액세스토큰을 바탕으로 사용자 정보를 요청하는 메소드를 수행시킨다.

public KakaoUserInfoProcessDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + accessToken);
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
    HttpEntity<MultiValueMap<String, String >> kakaoUserInfo = new HttpEntity<>(headers);
    RestTemplate rt = new RestTemplate();
    ResponseEntity<String> response = rt.exchange(
            kakaoConfig.getUser_info_uri(),
            HttpMethod.POST,
            kakaoUserInfo,
            String.class);

    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode jsonNode = objectMapper.readTree(response.getBody());
    String nickname = jsonNode.get("properties").get("nickname").asText();
    String email = jsonNode.get("kakao_account").get("email").asText();
    return new KakaoUserInfoProcessDto(nickname,email);
}

 

이후부턴 카카오서버가 제공한 액세스토큰이 아닌 우리 서비스 자체 Jwt를 생성하여 클라이언트에게 제공한다. 

코드 자체는 쉽고 직관적으로 작성하려고 노력했고, 읽기에도 편하게 읽힐 것 같다.

public ResponseEntity<ApiResponseWrapper> login(String code) throws JsonProcessingException {
    String kakaoAccessToken = getKakaoAccessToken(code); // 1. 액세스토큰 요청
    KakaoUserInfoProcessDto kakaoUserInfo = getKakaoUserInfo(kakaoAccessToken); // 2. 사용자 정보 반환
    String email = kakaoUserInfo.getEmail();
    String name = kakaoUserInfo.getName();
    // 3. 사용자 가입 유무 확인
    Member member = memberRepository.findByEmail(email).orElseGet(
            () -> memberRepository.save(new Member(email, name))
    );

    // 4. jwt 발급
    TokenDto allTokens = jwtUtil.createAllTokens(member.getMemberId(), member.getRole().toString());

    Optional<RefreshToken> existingRefreshToken = tokenRepository.findByEmail(member.getEmail());
    if (existingRefreshToken.isPresent()) {
        // 기존 리프레시 토큰 정보가 있는 경우, 새 리프레시 토큰으로 업데이트
        RefreshToken refreshToken = existingRefreshToken.get();
        refreshToken.updateToken(allTokens.getRefreshToken());
        tokenRepository.save(refreshToken);
    } else {
        // 기존 리프레시 토큰 정보가 없는 경우, 새로운 리프레시 토큰 저장
        tokenRepository.save(new RefreshToken(member.getEmail(), allTokens.getRefreshToken()));
    }
    LoginResponseDto loginResponseDto = new LoginResponseDto(allTokens.getAccessToken(),allTokens.getRefreshToken() ,member.getName(), member.getEmail());
    return ResponseEntity.ok(ApiResponseWrapper.success(loginResponseDto));
}

 


현재 개발중인 프로젝트는 비로그인 유저도 특정 범위까지는 서비스 이용이 가능해야한다. Security Config에서 api를 USER role 혹은 ADMIN role에게만 허용했던 것을 완화 시켜서 비로그인 접근 즉 Http Header에 Authorization 키가 존재하지 않아도 Controller 계층까지 request가 도달해야한다.

 

몇 차례 테스트를 진행하여 내린 결론은, 로그인 유저만 접근이 가능해야하는 api에 비로그인 유저가 접근할 경우, 응답되는 'Access Denied' 메시지는 Controller 계층에서 판별하여 보내는 것으로 방향을 잡았다. 예를 들어 마이페이지의 닉네임 변경 api의 경우 컨트롤러 단에서

@PreAuthorize("hasRole('USER')")

해당 어노테이션을 활용하여 접근제어를 수행시켰다.

 

추가로 발전시킨 부분으로 비로그인유저와 로그인유저 모두가 접근할 수 있어야 하는 api이다.

예를 들어 '특정 토픽 상세보기' api는 로그인유저와 비로그인유저에게 모두 접근이 가능해야한다. 다만, 로그인유저가 해당 api를 전송했을 때 응답되어야하는 데이터는 더 많다. 해당 topic을 스크랩했는지 유무, 알람 설정 유무 등과 같이 추가로 제공되어야하는 데이터가 존재한다.

 

따라서 구현 방식을 다음과 같이 하였다.

@GetMapping("/detail/{categoryId}/{topicId}")
public ResponseEntity<ApiResponseWrapper> detailTopic(
        @AuthenticationPrincipal CustomUserDetails userDetails,
        @PathVariable(name = "categoryId") Long categoryId,
        @PathVariable(name = "topicId") Long topicId
){
    return mainService.detailTopic(userDetails, categoryId, topicId);
}

 

모든 api마다 작성해두었던 @PreAuthorize 어노테이션은 제외하고 ContextHolder 내부의 사용자 인증객체를 @AuthenticationPrincipal 어노테이션을 활용하여 인자로 함께 받아오게 하였다. 

만약 비로그인 유저가 해당 api로 요청을 한다면 userDetails는 null 값을 가지게 된다. 사용자 인증 절차를 거치지 않았기 때문이다. 서비스계층에서 null인지 아닌지로 로그인 유무를 판별하여 dto를 구분하여 응답시킴으로서 전반적인 api 개발이 마무리되고 있는 중이다.

반응형