(Spring) 취향 기반 향수 추천 서비스 - 5. 세번째 기능 (카카오 Oauth 로그인)

김준석·2023년 3월 22일
0

향수 추천 서비스

목록 보기
6/21
post-thumbnail

목표

세번째 기능은 타인이 보는 나의 향수이다.

"나의 향수를 추천해줘"라는 링크를 타인에게 공유하고, 타인은 해당 링크에 접속하여, 첫번째 기능과 유사한 설문을 진행하고, 나는 타인이 추천해준 향수들을 조회할 수 있다.

이 기능은 로그인이 반드시 필요한 기능이기 때문에, 카카오 로그인을 사용하였다.

로그인 부분은, 아직 저에게 많이 어려운 주제라, 중간 중간 틀린 부분이 있을 수 있습니다 ㅜ.. 참고만 해주세요 !

구조

  1. 클라이언트는 카카오 서버로 인가코드를 요청한다.
  2. 카카오는 요청에 응답하여 인가코드를 응답한다.
  3. 클라이언트는 받은 인가코드를 백엔드 서버에 보낸다.
  4. 백엔드 서버는 클라이언트로부터 인가코드를 받아,Content-type, grant-type,client_id(rest Api 키),redirect_uri,인가코드)를 Map에 담아 카카오 서버에 토큰을 요청한다.
  5. 카카오 서버는 이를 검증하여, 토큰을 발급해준다.
  6. 백엔드 서버에서는 이 토큰을 활용하여 카카오 유저 정보(memberId, Nickname, Email, ThumbnailImage등을 받아온다.
  7. 해당 유저 정보에서 memberId를 이용하여 내부에서 새로운 Jwt 토큰을 발급한다.(비밀번호는 사용 안함)
  8. 해당 유저정보와 accessToken, RefreshToken을 담아 클라이언트에 전달한다. (이때, RefreshToken은 백엔드 서버에 저장해둔다)
  9. 클라이언트는 로그인이 필요한 서비스마다 accessToken을 Header에 담아 서버에 요청을 보낸다.
  10. 서버는 accessToken을 받아 유효성 검사를 한 뒤 로그인을 허용한다.
  11. 만약 Access토큰이 만료되었다면, 401에러를 반환하고 클라이언트는 이를 확인하여 AccessToken과 RefreshToken을 헤더에 담아 보내면서, AccessToken을 재발급 해달라는 요청을 한다
  12. 서버는 AccessToken과 Refresh토큰을 검증하여, 일치하면 새로운 Access토큰을 발급해준다.(반복)

개선 사항

  1. 6번에서 memberId를 그대로 사용하였을 때, 탈취당했을 경우 복호화 하여 사용자의 정보에 접근할 수 있다.
    -> 추후 개선사항이다. 현재 우리 서비스의 로그인을 활용한 기능은 타인이 추천해준 향수를 조회하는 것 밖에 없다. 그렇기에 보안에 큰 신경을 쓰지 않았다. 핑계..., 졸업 전시를 마치고 리팩토링을 해야겠다.
  2. 토큰 만료시간을 적절히 산정해야 한다. 토큰 만료시간이 짧으면, 새로운 토큰을 발급할 일이 많아지기 때문에, 서버에 부하가 된다. 우리 서비스는 aws 프리티어로 배포할 예정이기 때문에..이 부분을 고려해야 한다. 반면 토큰 만료시간이 길면, 서버 부하는 줄어들지만 토큰이 탈취당했을 경우 탈취자가 오랜 시간동안 토큰을 이용할 수 있기 때문에 보안상 좋지 않다.

그럼 어떻게 해야하지?

일단은 우리 서비스의 특성도 고려해야 한다. 우리 서비스는 MBTI 검사 서비스처럼, 한 사용자가 지속적으로 사용하는 서비스가 아니라, 여러 사용자가 가볍게 사용할 수 있는 서비스라고 생각한다.
3번째 서비스를 이용하는 사용자는 링크를 공유하여 친구들에게 추천을 받을 것이다. 사용자는 일정 시간마다 접속하여 타인이 추천해준 향수 결과를 확인할 것이다.
나중에 배포하고 로그를 봐야 알겠지만, 대략 30분~1시간 주기로 확인을 할 것이라고 예상이 된다.
그렇기 때문에 AccessToken의 만료 시간을 30분~1시간 정도로 설정을 할 예정이다.

RefreshToken은 일반적으로 2주정도의 기간으로 설정한다고 알고있다. 우리 서비스는 지속적인 서비스가 아닌 가볍게 사용하는 서비스이기 때문에, 일반적인 기준에 따라 기간을 설정할 예정이다.

구현

OauthType.java

@Getter
public enum OauthType {

    CONTENT_TYPE("Content-type","application/x-www-form-urlencoded;charset=utf-8"),
    GRANT_TYPE("grant_type","authorization_code"),
    CLIENT_ID("client_id","RESTAPI 주소!"),
    REDIRECT_URI("redirect_uri","Redirect URI");

    private String name;
    private String type;

    OauthType(String name, String type) {
        this.name = name;
        this.type = type;
    }

}

사용될 RestApi 주소와 상수들을 관리하는 Enum객체를 생성하였다.

Service

OauthService.java

@Service
public class OauthService {
    private final MemberService memberService;

    private OauthService(MemberService memberService) {
        this.memberService = memberService;
    }

    private HttpHeaders setHttpHeaders() {
        HttpHeaders httpHeaders = new HttpHeaders();

        httpHeaders.add(OauthType.CONTENT_TYPE.getName(), OauthType.CONTENT_TYPE.getType());
        return httpHeaders;
    }

    private LinkedMultiValueMap<String, String> setHttpBody(String code) {
        LinkedMultiValueMap<String, String> accessTokenParams = new LinkedMultiValueMap<>();

        accessTokenParams.add(OauthType.GRANT_TYPE.getName(), OauthType.GRANT_TYPE.getType());
        accessTokenParams.add(OauthType.CLIENT_ID.getName(), OauthType.CLIENT_ID.getType());
        accessTokenParams.add(OauthType.REDIRECT_URI.getName(), OauthType.REDIRECT_URI.getType());
        accessTokenParams.add("code", code);

        return accessTokenParams;
    }

    private RestTemplate createRestTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        return restTemplate;
    }

    //여기까지 토큰 값 받기
    public ResponseEntity<String> getResponseFromServer(String url, String code, HttpHeaders httpHeaders) {
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>((setHttpBody(code)), httpHeaders);

        ResponseEntity<String> response = createRestTemplate().exchange(url, HttpMethod.POST, request, String.class);

        return response;
    }

    public Member loadUserProfile(String code, HttpSession httpSession) {
        try {
            JSONParser jsonParser = new JSONParser();
            HttpHeaders httpHeaders = setHttpHeaders();
            JSONObject jsonObject = (JSONObject) jsonParser.parse(getResponseFromServer("https://kauth.kakao.com/oauth/token", code, httpHeaders).getBody());
            httpSession.setAttribute("Authorization", jsonObject.get("access_token"));

            httpHeaders.add("Authorization", "Bearer " + jsonObject.get("access_token"));

            ResponseEntity<String> responseEntity = getResponseFromServer("https://kapi.kakao.com/v2/user/me", code, httpHeaders);

            JSONObject profile = (JSONObject) jsonParser.parse(responseEntity.getBody());
            JSONObject properties = (JSONObject) profile.get("properties");
            JSONObject kakaoAccount = (JSONObject) profile.get("kakao_account");

            MemberRequestDto memberRequestDto = MemberRequestDto.builder()
                    .memberId((Long) profile.get("id"))
                    .nickname((String) properties.get("nickname"))
                    .email((String) kakaoAccount.get("email"))
                    .thumbnailImage((String) properties.get("thumbnail_image"))
                    .build();
            saveUserProfile(memberRequestDto);
            return memberService.findByMemberPk(memberRequestDto.getMemberId());

        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
    }

    public void saveUserProfile(MemberRequestDto memberRequestDto) {
        Member member = Member.builder()
                .memberId(memberRequestDto.getMemberId())
                .email(memberRequestDto.getEmail())
                .memberId(memberRequestDto.getMemberId())
                .nickname(memberRequestDto.getNickname())
                .thumbnailImage(memberRequestDto.getThumbnailImage())
                .build();
        isAgreeEmailUsing(memberRequestDto.getEmail());

        if (!memberService.isAlreadyExistMember(memberRequestDto)) {
            memberService.saveMemberProfile(member);
        }
    }

    public boolean isAgreeEmailUsing(String email) {
        if (email == null) {
            throw new EmailNotFoundException();
        }
        return true;
    }
}
  • setHttpHeader()는 CONTENT_TYPE를 헤더에 담는 메서드이다.
  • setHttpBody()는 토큰을 요청하기 위해 Body에 필요한 것들을 담는 메서드이다. GRANT_TYPE와 RESTAPI키,REDIRECT_URI를 Key,value로 Body에 담아준다.
    -getResponseFromServer()는 앞의 메서드에서 만든 header와 body를 통해 요청을 보내고 받는 메서드이다.
  • loadUserProfile()메서드는 토큰을 통해 카카오로부터 유저 정보를 받아오는 메서드이다. 해당 유저정보를 받아와 유저정보를 서버에 저장한다. (메서드가 너무 많은 일을 해서 추후에 리팩토링 할 예정이다.)
  • saveUserProfile() 메서드는 유저정보를 저장해주는 메서드이다. 이때 유저정보가 이미 있을 경우엔 저장하지 않는다.
  • isAgreeEmailUsing()메서드는 이메일을 동의하지 않았을 시 EmailNotFoundException을 발생시킨다.
    생각해보니 동의하지 않았는데 NotFoundException을 발생시키는 게 이상하다.. 추후에 Exception을 새로 커스터마이징 해야겠다.

Controller

OauthController.java

@RestController
@RequestMapping("/oauth")
public class OauthController {

    private final OauthService oauthService;

    private final LoginService loginService;

    public OauthController(OauthService oauthService, LoginService loginService) {
        this.oauthService = oauthService;
        this.loginService = loginService;
    }

    @GetMapping("/login")
    public ResponseEntity<LoginResponse> signUp(@RequestParam String code, HttpSession httpSession) {

        Member member = oauthService.loadUserProfile(code, httpSession);

        return ResponseEntity.ok(loginService.generateToken(member.getMemberId()));
    }

다음 글에서 Jwt를 활용할 것이다.

이미지 출처
padd60님의 카카오 로그인 뿌수기

profile
기록하면서 성장하기!

0개의 댓글