[Spring Boot] JWT + 소셜 로그인 (2) - 서비스/컨트롤러 구현

susu·2022년 8월 26일
1

JWT + 소셜 로그인

목록 보기
2/3
post-thumbnail

이 글은 이틀간의 논스탑바보짓밤샘에러해결쇼의 결과물에 대한 기록입니다.
아하하... 힘들고 막막했지만 그래도 해결했으니 되었다~!

제가 진행하고 있는 프로젝트를 기준으로 작성했습니다.
더 나은 로직이 (⚡️얼마든지⚡️) 있을 수 있으므로 참고하는 용으로만 봐주시고,
보완해야 할 점이나 잘못된 부분이 있으면 댓글로 알려주세요!
기재되어 있지 않은 부분에 대해 궁금하신 분은 댓글로 질문해주시면 아는 선에서 답변 드리겠습니다 👍

🗂 패키지 구조 요약

Service 단

  • 회원가입, 로그인 로직을 처리할 AuthService
  • 모든 토큰 발급 및 검증 로직을 처리할 SecurityService

Controller 단

  • 회원가입, 로그인 API 엔드포인트와 액션을 정의한 AuthController

🍏 AuthService

Service 클래스에서는 비즈니스 로직을 처리하기 위한 메소드들을 구현한다.

고민해본 점들

  1. 가독성 고려
    컨트롤러 단에서 서비스 메소드들을 일일이 호출할 것인지,
    서비스 상에 전체를 아우르는 메소드를 하나 만들어서 그 메소드만 호출할 것인지.
    -> 후자 선택. 컨트롤러 단에서는 전체적인 흐름을 확인할 수 있게 하고 싶어서 복잡한 로직은 서비스 단에서 해결하기로 했다.

  2. 메소드 재사용성 고려
    다른 소셜 로그인을 구현할 때에도 재사용하기 좋은 코드를 짜고 싶었다.
    메소드 파라미터로 어떤 것을 받으면 좋을지 고민해서 코드를 짰다.

  3. 요청/응답에 대한 body 형식
    웹과 JSON 형식으로 응답을 주고받는 상황이고,
    로그인 과정의 요청과 응답 양식은 고정적이므로 일일이 JSON body를 작성하는 것 대신 로그인 요청에 대한 DTO를 따로 만들기로 했다.

이를 바탕으로 구상한 메소드들은 다음과 같다.
메소드 설명은 주석으로 달아두었음!

1. getKakaoAccessToken()

인가코드로 kakaoAccessToken 따오는 메소드.
public kakaoTokenDto getkakaoAccessToken(String code)
파라미터: 인가코드 code
반환형: kakaoTokenDto

	public KakaoTokenDto getKakaoAccessToken(String code) {

        RestTemplate rt = new RestTemplate(); //통신용
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpBody 객체 생성
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code"); //카카오 공식문서 기준 authorization_code 로 고정
        params.add("client_id", KAKAO_CLIENT_ID); //카카오 앱 REST API 키
        params.add("redirect_uri", KAKAO_REDIRECT_URI);
        params.add("code", code); //인가 코드 요청시 받은 인가 코드값, 프론트에서 받아오는 그 코드

        // 헤더와 바디 합치기 위해 HttpEntity 객체 생성
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(params, headers);
        System.out.println(kakaoTokenRequest);

        // 카카오로부터 Access token 수신
        ResponseEntity<String> accessTokenResponse = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // JSON Parsing (-> KakaoTokenDto)
        ObjectMapper objectMapper = new ObjectMapper();
        KakaoTokenDto kakaoTokenDto = null;
        try {
            kakaoTokenDto = objectMapper.readValue(accessTokenResponse.getBody(), KakaoTokenDto.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return kakaoTokenDto;
    }

2. getKakaoInfo()

kakaoAccessToken 으로 카카오 로그인 서버에 정보 요청하는 메소드.
public KakaoAccountDto getKakaoInfo(String kakaoAccessToken)
파라미터: 카카오 Access Token kakaoAccessToken
반환형: kakaoAccountDto

    public KakaoAccountDto getKakaoInfo(String kakaoAccessToken) {

        RestTemplate rt = new RestTemplate();

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

        HttpEntity<MultiValueMap<String, String>> accountInfoRequest = new HttpEntity<>(headers);

        // POST 방식으로 API 서버에 요청 보내고, response 받아옴
        ResponseEntity<String> accountInfoResponse = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                accountInfoRequest,
                String.class
        );

        System.out.println("카카오 서버에서 정상적으로 데이터를 수신했습니다.");
        // JSON Parsing (-> kakaoAccountDto)
        ObjectMapper objectMapper = new ObjectMapper();
        KakaoAccountDto kakaoAccountDto = null;
        try {
            kakaoAccountDto = objectMapper.readValue(accountInfoResponse.getBody(), KakaoAccountDto.class);
        } catch (JsonProcessingException e) { e.printStackTrace(); }

        return kakaoAccountDto;

    }

3. mapKakaoInfo()

kakaoAccountDto 를 Account 객체로 매핑하는 메소드.
public Account mapKakaoInfo(KakaoAccountDto accountDto)
파라미터: KakaoAccountDto kakaoAccountDto
반환형: Account

	public Account mapKakaoInfo(KakaoAccountDto accountDto) {

        // kakaoAccountDto 에서 필요한 정보 꺼내기
        Long kakaoId = accountDto.getId();
        String email = accountDto.getKakao_account().getEmail();
        String nickname = accountDto.getKakao_account().getProfile().getNickname();

        System.out.println("매핑한 정보 : " + email + ", " + nickname);
        // Account 객체에 매핑
        return Account.builder()
                .socialId(kakaoId)
                .email(email)
                .nickname(nickname)
                .role("USER")
                .build();
    }

🔽 4, 5번은 컨트롤러로 직행(?) 할 전체적인 로직을 담은 메소드들이다.

4. kakaoLogin()

login 요청 보내는 회원가입 유무 판단해 분기 처리할 메소드.
public LoginResponseDto kakaoLogin(String kakaoAccessToken)
파라미터: String kakaoAccessToken
반환형: LoginResponseDto

    public LoginResponseDto kakaoLogin(String kakaoAccessToken) {

        // kakaoAccessToken 으로 카카오 회원정보 받아옴
        KakaoAccountDto kakaoAccountDto = getKakaoInfo(kakaoAccessToken);
        String kakaoEmail = kakaoAccountDto.getKakao_account().getEmail();

        // kakaoAccountDto 를 Account 로 매핑
        Account selectedAccount = mapKakaoInfo(kakaoAccountDto);
        System.out.println("수신된 account 정보 : " + selectedAccount);

        // 매핑만 하고 DB에 저장하질 않았으니까 Autogenerated 인 id가 null 로 나왔던거네 아... 오케오케 굿

        LoginResponseDto loginResponseDto = new LoginResponseDto();
        loginResponseDto.setKakaoAccessToken(kakaoAccessToken);
        // 가입되어 있으면 해당하는 Account 객체를 응답
        // 존재하면 true + access token 을, 존재하지 않으면 False 리턴
        if (accountRepository.existsByEmail(kakaoEmail)) {
            Account resultAccount = accountRepository.findByEmail(kakaoEmail);
            loginResponseDto.setLoginSuccess(true);
            loginResponseDto.setKakaoAccessToken(kakaoAccessToken);
            loginResponseDto.setAccount(resultAccount);
            System.out.println("response setting: " + loginResponseDto);

            // 토큰 발급
            TokenDto tokenDto = securityService.login(resultAccount.getId());
            loginResponseDto.setAccessToken(tokenDto.getAccessToken());
            System.out.println("로그인이 확인됐고, 토큰을 발급했습니다.");
        }
        return loginResponseDto;
    }

5. kakaoSignup()

회원가입 처리 메소드
public Long kakaoSignUp(SignupRequestDto requestDto)
파라미터: SignupRequestDto requestDto
반환형: Long accountId

    public Long kakaoSignUp(SignupRequestDto requestDto) {

        KakaoAccountDto kakaoAccountDto = getKakaoInfo(requestDto.getKakaoAccessToken());
        Account account = mapKakaoInfo(kakaoAccountDto);

        // 닉네임, 프로필사진 set
        String accountName = requestDto.getAccountName();
        String accountPicture = requestDto.getPicture();
        account.setAccountName(accountName);
        account.setPicture(accountPicture);

        // DB에 save
        accountRepository.save(account);

        // 회원가입 결과로 회원가입한 accountId 리턴
        return account.getId();
    }

🍏 AuthController

🔧 GET /login/kakao

먼저 모든 로그인 요청을 처리할 API를 만들어봤다.
모든 소셜 로그인 요청에 대해서 발생하는 인가코드를 params로 받아 가입된 사용자인지 아닌지 확인해 결과를 응답해주어야 한다.

request로 온 사용자 정보를 DB에서 검색해 DB에 존재하면 boolean 타입으로 로그인 성공 유무를 true로 설정하고 사용자 정보를 불러올 수 있는 Access token을 리턴한다.
가입되지 않은 사용자로 확인되면 로그인 성공 유무를 false로 설정한다.

코드 설명

@GetMapping("/login/kakao")
public ResponseEntity<LoginResponseDto> kakaoLogin(HttpServletRequest request) {

    String code = request.getParameter("code");
    System.out.println(code);
    KakaoTokenDto kakaoTokenDto = authService.getKakaoAccessToken(code);
    System.out.println("kakaoTokenDto: " + kakaoTokenDto);
    String kakaoAccessToken = kakaoTokenDto.getAccess_token();
    System.out.println("kakaoAccessToken: " + kakaoAccessToken);

    // 토큰 발급까지 authService.kakaologin 상에서 다 처리하자
    LoginResponseDto loginResponseDto = authService.kakaoLogin(kakaoAccessToken);

    return ResponseEntity.ok(loginResponseDto);
}

🔧 POST /signup

가입되지 않은 사용자일 경우 프론트 상에서 가입 창으로 리다이렉트 시킬 것이다.
리다이렉트된 컴포넌트에서 state로 닉네임과 프로필 사진을 입력받아 Account 객체에 담고, 이 객체를 repository에 save하도록 했다.

코드

@PostMapping("/signup")
public ResponseEntity<SignupResponseDto> kakaoSignup(@RequestBody SignupRequestDto requestDto) {

    // requestDto 로 데이터 받아와서 accountId 반환
    Long accountId = authService.kakaoSignUp(requestDto);

    // 최초 가입자에게는 RefreshToken, AccessToken 모두 발급
    TokenDto tokenDto = securityService.signup(accountId);

    // AccessToken 은 header 에 세팅하고, refreshToken 은 httpOnly 쿠키로 세팅
    SignupResponseDto signUpResponseDto = new SignupResponseDto();
    HttpHeaders headers = new HttpHeaders();
    ResponseCookie cookie = ResponseCookie.from("RefreshToken", tokenDto.getRefreshToken())
                    .maxAge(60*60*24*7) // 쿠키 유효기간 7일로 설정했음
                    .path("/")
                    .secure(true)
                    .sameSite("None")
                    .httpOnly(true)
                    .build();
    headers.add("Set-Cookie", cookie.toString());
    headers.add("Authorization", tokenDto.getAccessToken());

    signUpResponseDto.setResult("가입이 완료되었습니다.");
    return ResponseEntity.ok().headers(headers).body(signUpResponseDto);
}

결과 확인하기

  • 가입 안 된 계정으로 접근
    -> loginSuccess = false 리턴

  • 발급받은 정보로 회원가입 시도

  • 다시 로그인 시도

*

전체 코드 확인하기 ➡️ 🐱 깃허브 🐱
다음 글에서는 토큰 발급 및 검증 과정을 정리해볼 예정이다.

profile
블로그 이사했습니다 ✈ https://jennairlines.tistory.com

2개의 댓글

comment-user-thumbnail
2023년 3월 21일

다음 글은 안올라오나유..

1개의 답글