[Spring Boot] Spring Security Form Login + Kakao Login (Social Login) + JWT 인증 구현

임원재·2024년 7월 24일
0

SpringBoot

목록 보기
6/18
post-thumbnail

저번에 Form Login을 구현하였고, JWT토큰과 Spring Security를 활용해 인증과 인가 처리를 구현하였다. 이번에는 여기에 Social Login인 Kakao Login을 구현하려고 한다.

[Spring Boot] Spring Security Form Login + JWT 인증 구현

해당 Form Login + JWT 인증에서 덧붙여 구현하므로 해당 페이지에서 구현한 클래스들을 사용할 예정이다.


소셜 로그인

사용자(클라이언트)로 하여금 소셜 네트워킹 계정을 이용해 다른 웹사이트나 서비스에 쉽게 로그인할 수 있도록 하는 인증 방식이다. OAuth2.0 프로토콜을 통해 구현되며, 대표적인 provider로 구글, 페이스북, 깃허브, 네이버, 카카오 등이 있다.

구성 요소

  • Resource Owner (리소스 소유자) : 서비스를 사용하려는 사용자, 보호된 리소스의 소유자이다. 현재 개발하는 서비스에서 해당 사용자의 보호된 리소스에 접근하는 것이 목표이다.
  • Client (클라이언트) : 보통 클라이언트는 사용자를 의미하지만, OAuth2.0에서는 현재 개발하고 있는 서버(서비스의 서버)를 의미한다. 카카오에게 사용자의 정보를 요청하는 입장이기 때문이다.
  • Authorization Server (인증 서버) : 클라이언트의 인증 요청을 처리하고 토큰을 발급한다. 예로 네이버, 카카오, 구글 등이 있다.

일반적인 클라이언트-서버 관계가 아닌 개발 쪽이 클라이언트가 되어 카카오 인증 서버에게 요청을 보내 사용자의 정보를 가져와야 하는 것이다.

  • 기존의 폼 로그인 방식은 사용자가 직접 아이디와 비밀번호를 설정하여 계정을 생성한 뒤, 부가 정보를 직접 입력하는 방식인 반면,
  • 소셜 로그인은 직접 계정을 생성하지 않고 provider로 하여금 자신이 사용하려고 하는 서비스에게 개인정보 리소스에 접근할 수 있도록 허용시킨다. 이에 서비스는 리소스를 받아와 사용자 계정 정보를 조회하여 로그인하거나, 새로 계정을 생성하게 된다.
  • 즉, 정리하자면 소셜 로그인은 소셜 인증 서버에서 사용자의 정보를 가져오는 과정이다.

구글, 네이버, 카카오 등의 여러 소셜 로그인을 구현 가능하지만 일단은 카카오를 먼저 구현하여 감을 잡아야겠다고 생각했다.

후에 여러 provider들을 추가하여 하나의 인터페이스와 이에 여러 개의 provider의 구현체를 생성하는 객체지향적으로 구현해볼 계획이다.

참고로 oauth2.0 프로토콜을 사용할 수 있는 Spring OAuth2 Client 구현체가 존재하지만 이를 사용하지 않고 구현할 예정이다.
Spring OAuth2 Client에서 제공하는 인증 구현체인 DefaultOAuth2UserService를 상속하여 사용할 수도 있지만 현재 폼로그인을 JWT인증을 이용하여 인증을 자체적으로 진행하기 때문에 OAuth2방식으로 카카오 사용자의 정보를 가져오는 로직만 구현할 것이다.

후에 언급할 환경변수들의 경로들은 Spring OAuth2 Client를 준수하도록 설정하였다. 후에 Spring OAuth2 Client를 사용하는 것을 염두에 두고 있기 때문이다.


카카오 로그인

Kakao Developers

다음과 같이 Kakao Developers에는 다양한 제품에 대한 사용 방법을 설명한다.

그 중 카카오 로그인의 REST API를 살펴보면 된다.

로그인을 구현하기 전, 개발하는 애플리케이션을 카카오 내 애플리케이션에 등록해야 한다.

애플리케이션 등록

  1. 다음과 같이 애플리케이션을 추가한다.

  1. 앱 설정 >> 앱 키에서 위와 같은 키를 확인할 수 있다.

    애플리케이션을 식별하는 키이다.

    Web 애플리케이션을 개발하므로 REST API 키를 식별 키로 사용할 것이다.


  1. 앱 설정 >> 플랫폼에서 Web에 대한 도메일을 설정한다.

    현재 사용하는 http://localhost:8080을 입력한다.


  1. 서비스 사용자가 카카오 로그인을 진행한 후 카카오 인증 서버에서 localhost:8080의 특정 url로 인증 코드를 전송하여 로그인 로직을 진행해야 한다.

    이를 로그인 후 해당 url로 리다이렉트 되므로 Redirect URI라고 부른다.

    제품 설정 >> 카카오 로그인에서 위 사진과 같이 Redirect URI를 설정할 수 있다.

    만약 이를 프론트에서 처리한다면 프론트와 협의하여 localhost:3000 로 시작하는 URI를 입력하면 된다.

    해당 주소로는 localhost:8080/oauth/kakao/callback?code=로 code를 담아 리다이렉트된다.

    Controller에 해당 URI에 맞는 핸들러를 생성해야 한다.


  1. 제품 설정 >> 동의 항목에서 가져올 사용자의 정보를 설정할 수 있다.

    사용자는 로그인 시 해당 항목들을 사용하는 것에 동의를 진행하게 된다.



Service Client는 Resource Owner, Service Server는 Client, Kakao Auth Server는 Auth Server이다. 3개의 객체의 상호작용으로 카카오 로그인이 진행된다.

위에서 언급했듯이, 소셜 로그인은 카카오 인증 서버에서 사용자의 정보를 가져오는 과정이다.

과정을 간단히 요약하자면 다음과 같다.

  1. 사용자는 카카오톡으로 로그인 버튼을 클릭하고 로그인 진행 → 카카오 인증 서버로 전송
  2. 카카오 인증 서버는 우리가 설정한 Redirect URI로 인가 코드 전달
  3. 서비스 서버(개발 서버)는 받아온 인가 코드로 카카오 인증 서버로 accessToken 요청
  4. 카카오 인증 서버는 올바른 인가 코드일 때 accessToken 발급
  5. 서비스 서버(개발 서버)는 accessToken을 전송하여 인증 서버로 사용자 정보 조회
  6. 받아온 사용자 정보로 로그인 or 회원가입 진행

더욱더 요약하자면 아래와 같이 요약할 수 있겠다.

사용자 로그인 → 인가 코드 → accessToken → 사용자 정보 받기


  • Service Client가 사용하려는 서비스의 카카오로 로그인하기를 클릭한 상태라고 가정해보자

인가 코드 받기

해당 API는 카카오 로그인 동의 화면을 호출하고, 사용자의 정보 제공 동의 후 인가 코드를 발급한다. 이때 인가 코드는 위에서 등록한 Redirect URI로 전송된다.

아래와 같이 구현 가능하다.

OAuth2Controller

@Controller
@RequestMapping("/api/v1/oauth")
public class OAuth2Controller {

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String client_id;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String redirect_uri;

    @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}")
    private String url;

    @GetMapping("/kakao-login")
    public String kakaoLogin() {
        String redirect = url
                + "?client_id="
                + client_id
                + "&redirect_uri="
                + redirect_uri
                + "&response_type=code";
        return "redirect:" + redirect;
    }
}

서비스 사용자가 카카오로 로그인하기 버튼을 클릭했을 때 실행된다고 보면 된다. 이에 로그인 화면이 호출되고 로그인했음을 알리는 인가코드가 개발 서버로 넘어가게 된다.

url?를 사용하여 쿼리 파라미터를 입력한다. 해당 API에서 필요한 파라미터는 아래와 같다.

또한 application.properties에서 client_id, redirect_uri, url에 대한 변수들을 설정해 주어야 한다.

client_id애플리케이션 등록할 때 발급받은 REST API 키
redirect_uri애플리케이션 등록할 때 설정한 Redirect URI
response_typecode로 고정

url을 설정하고 return "redirect:" + redirect;로 해당 url로 redirect되도록 설정하였다.

https://kauth.kakao.com/oauth/authorize?client_id=123412341234123412341234&redirect_uri=http://localhost:8080/oauth/kakao/callback&response_type=code와 같은 형식으로 redirect 된다.

로그인을 성공적으로 마쳤다면 설정한 Redirect 주소(http://localhost:8080/oauth/kakao/callback)로 인가 코드가 전달된다.

하지만 해당 주소에 해당하는 컨트롤러를 생성하지 않았으므로 로그인 후에는 오류 페이지가 뜰 것이다.

인가 코드를 받는 컨트롤러는 아래와 같다.

KakaoLoginController

@RestController
@Slf4j
@RequiredArgsConstructor
public class KakaoLoginController {

    private final KakaoLoginService kakaoLoginService;
    /**
     * 카카오 로그인 후 리다이렉트 주소인 /oauth/kakao/callback으로 받아온 AuthCode를 처리
     * @param code
     * @return
     */
    @GetMapping("/oauth/kakao/callback")
    public ResponseEntity<JwtInfoDto> callback(String code) {
        KakaoTokenDto.Response kakaoTokenResponseDto = kakaoLoginService.getKakaoToken(code);
        JwtInfoDto jwtInfoDto = kakaoLoginService.kakaoLogin(kakaoTokenResponseDto.getAccess_token());
        return ResponseEntity.ok(jwtInfoDto);
    }
}

해당 핸들러에서는 String code로 인가코드를 받아와 JwtInfoDto를 반환한다.

여기서 JwtInfoDto는 카카오 인증 서버에서 응답으로 받은 것이 아니라 자체적으로 JwtAuthenticationFilter를 구현하여 해당 filter에서 인증을 진행하기 위해 만든 JWT토큰dto이다.

즉, 위 핸들러에서 KakaoToken을 받아오고, 해당 Token을 통해 사용자 정보를 받아와 로그인을 진행한다.

KakaoTokenDto.Response kakaoTokenResponseDto = kakaoLoginService.getKakaoToken(code);

위 코드는 인가 코드로 카카오토큰을 받아오는 메서드이다. 이는 KakaoLoginService에서 구현되었다.

토큰 받기

해당 API는 인가코드로 토큰을 받아오는 API이다.

요청으로 필요한 파라미터는 위 사진과 같다.

아래와 같이 RestTemplate을 사용하여 요청을 전송하고 받아오는 코드를 구현하였다.

(뒤에서 메서드들을 합쳐 KakaoLoginService를 적을 예정이다. 아래 코드로는 오류가 날 수 밖에 없다)

    public KakaoTokenDto.Response getKakaoToken(String code) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Content-type", content_type);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        String grant_type = "authorization_code";
        params.add("grant_type", grant_type);
        params.add("client_id", client_id);
        params.add("redirect_uri", redirect_uri);
        params.add("code", code);
        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);

        ResponseEntity<KakaoTokenDto.Response> response = restTemplate.exchange(url, HttpMethod.POST, entity, KakaoTokenDto.Response.class);

        return response.getBody();
    }

KakaoTokenDto.Response가 인가코드로 응답을 담을 객체이므로 API문서에 맞게 생성하였다.

public class KakaoTokenDto {

    @Builder
    @Getter
    public static class Request{
        private String grant_type;
        private String client_id;
        private String redirect_uri;
        private String code;
    }

    @Builder @Getter @ToString
    public static class Response{
        private String token_type;
        private String access_token;
        private Integer expires_in;
        private String refresh_token;
        private Integer refresh_token_expires_in;
        private String scope;

    }
}

이와 같이 카카오 토큰을 받아왔다. 다시 KakaoLoginController를 보자

JwtInfoDto jwtInfoDto = kakaoLoginService.kakaoLogin(kakaoTokenResponseDto.getAccess_token());
return ResponseEntity.ok(jwtInfoDto);

accessToken으로 kakaoLogin을 호출하여 이를 JwtInfoDto로 받는다.

kakaoLogin에서 사용자 정보를 가져와 JwtInfoDto를 만든다고 예상할 수 있다.

public JwtInfoDto kakaoLogin(String accessToken) {
    KakaoInfoResponseDto userInfoResponseDto = getKakaoUserInfo(accessToken);
    KakaoInfoResponseDto.KakaoAccount kakaoAccount = userInfoResponseDto.getKakaoAccount();
    String email = kakaoAccount.getEmail();

    System.out.println("email = " + email);

    Member member;
    Optional<Member> optionalMember = memberRepository.findByEmail(email);
    if(optionalMember.isEmpty()) {

        member = Member.builder()
                .email(email)
                .password("dummy data")
                .type(Type.KAKAO)
                .role(Role.USER)
                .build();
        memberRepository.save(member);

    } else {
        member = optionalMember.get();
    }
    return jwtUtil.createToken(member.toMemberInfoDto());
}

private KakaoInfoResponseDto getKakaoUserInfo(String accessToken) {
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + accessToken);
    headers.set("Content-type", content_type);

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

    ResponseEntity<KakaoInfoResponseDto> userInfoResponseDto = restTemplate.exchange(get_info_url, HttpMethod.GET, entity, KakaoInfoResponseDto.class);

    return userInfoResponseDto.getBody();
}

kakaoLogingetKakaoUserInfo가 있다, 먼저 kakaoLogin을 보자

getKakaoUserInfo를 통해 사용자 정보를 가져온다. 좀 더 자세한 설명은 밑에서 할 예정이다.

userInfoResponseDto에서 email을 추출하여 해당 email을 가진 멤버를 찾는다.

해당 email을 가진 멤버가 없으면, 새로 member를 생성한다. 이때 소셜로그인은 비밀번호를 사용하지 않으므로, 더미 값을 넣어 처리하는 것으로 결정하였다.

member가 있으면 해당 member를 가져온다.

이제 member를 MemberInfoDto로 변환하여 jwtUtilcreateToken메서드를 호출하여 JwtInfoDto를 반환한다.

사용자 정보 가져오기

이제 accessToken으로 카카오 사용자 정보를 받아오는 getKakaoUserInfo메서드를 구현해야 한다.

필요 파라미터는 헤더에 Bearer ${accessToken}을 넣으면 된다.

응답 형식은 위와 같다.

위에서 언급했듯이 provider마다 응답 형식이 다르므로 문서를 확인해야 한다.

필자가 필요로 하는 email은 KakaoAccount 안에 있으므로 이를 dto로 변환하여 사용하였다.

@Getter
public class KakaoInfoResponseDto {
    private Long id;

    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @Getter
    public static class KakaoAccount {
        private String email;

        @JsonProperty("profile")
        private Profile profile;

        @Getter
        public static class Profile {
            private String nickname;
        }
    }
}

구현 메서드는 아래와 같다.

private KakaoInfoResponseDto getKakaoUserInfo(String accessToken) {
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + accessToken);
    headers.set("Content-type", content_type);

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

    ResponseEntity<KakaoInfoResponseDto> userInfoResponseDto = restTemplate.exchange(get_info_url, HttpMethod.GET, entity, KakaoInfoResponseDto.class);

    return userInfoResponseDto.getBody();
}

토큰 받을 때와 마찬가지로 RestTemplate을 사용하여 구현하였다.

KakaoLoginService

@Service
@Slf4j
@RequiredArgsConstructor
public class KakaoLoginService {

    private final RestTemplate restTemplate = new RestTemplate();
    private final MemberRepository memberRepository;
    private final JwtUtil jwtUtil;

    private final String content_type = "application/x-www-form-urlencoded;charset=utf-8";

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String client_id;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String redirect_uri;

    @Value("${spring.security.oauth2.client.provider.kakao.token-uri}")
    private String url;

    @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}")
    private String get_info_url;

    public KakaoTokenDto.Response getKakaoToken(String code) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Content-type", content_type);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        String grant_type = "authorization_code";
        params.add("grant_type", grant_type);
        params.add("client_id", client_id);
        params.add("redirect_uri", redirect_uri);
        params.add("code", code);
        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);

        ResponseEntity<KakaoTokenDto.Response> response = restTemplate.exchange(url, HttpMethod.POST, entity, KakaoTokenDto.Response.class);

        return response.getBody();
    }

    public JwtInfoDto kakaoLogin(String accessToken) {
        KakaoInfoResponseDto userInfoResponseDto = getKakaoUserInfo(accessToken);
        KakaoInfoResponseDto.KakaoAccount kakaoAccount = userInfoResponseDto.getKakaoAccount();
        String email = kakaoAccount.getEmail();

        System.out.println("email = " + email);

        Member member;
        Optional<Member> optionalMember = memberRepository.findByEmail(email);
        if(optionalMember.isEmpty()) {

            member = Member.builder()
                    .email(email)
                    .password("dummy data")
                    .type(Type.KAKAO)
                    .role(Role.USER)
                    .build();
            memberRepository.save(member);

        } else {
            member = optionalMember.get();
        }
        return jwtUtil.createToken(member.toMemberInfoDto());
    }

    private KakaoInfoResponseDto getKakaoUserInfo(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);
        headers.set("Content-type", content_type);

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

        ResponseEntity<KakaoInfoResponseDto> userInfoResponseDto = restTemplate.exchange(get_info_url, HttpMethod.GET, entity, KakaoInfoResponseDto.class);

        return userInfoResponseDto.getBody();
    }
}

여기까지 카카오 인증 서버를 통해 사용자의 정보를 가져와 이를 member로 저장하고 JwtInfoDto를 생성하여 폼로그인과 같이 JWT인증을 사용할 수 있도록 완료하였다.

마지막으로 SecurityConfig에서 호출할 url에 대한 permit처리를 하면 완료된다.


실행

카카오로그인 url인 localhost:8080/api/v1/oauth/kakao-login을 실행하였다.

미리 로그인이 되어있어서 앞서 구현한 일련의 API호출 완료 후 직접 만든 JWT토큰 형식의 JwtInfoDto가 return되었다.

db에도 잘 들어갔음을 확인할 수 있다.

위에서 받은 JwtInfoDtoaccessToken으로 인증이 필요한 컨트롤러를 스웨거에서 호출하였더니 다음과 같이 authorized 페이지에 접근 허용됨을 확인할 수 있었다.


정리

폼로그인에서 입력받은 정보로 member를 찾는 로직과,

소셜로그인에서 인증 서버에서 사용자의 정보를 가져와 이를 이용해 member를 생성하거나 찾는 로직은 다르지만,

이 찾은 member를 통해 사용자 정의 JWT토큰을 생성하고 인증을 진행하는 로직은 폼로그인이나 소셜로그인이나 동일한 로직을 통해 진행된다. (같은 member를 사용하기 때문)

앞으로 또 다른 소셜 인증 서버가 추가되더라도 이와 같은 로직을 통해 손쉽게 추가 가능할 것이다.


처음 spring boot에서 로그인 관련 로직을 배울 때는 아무것도 머리속에 들어오지 않고 이걸 어떻게 해야하나 막막함이 있었다.

그래서 구현을 해도 이게 맞나 싶게 되고 조금만 변형을 하려고 해도 어디를 어떻게 만져야 될지 감이 안왔었다. 아마 모든걸 어중간하게 알고 있어서 그랬다고 생각한다. 완전히 알지 못하는걸 안다고 생각했으므로 당연히 뭔가를 구현하려고 해도 이해가 떨어져 잘 되지 않았다.

이에 아예 모든걸 모른다고 생각하고 처음부터 하나씩 제대로 이해하며 정리하며 글을 써오니 어느새 Spring Security부터 Form Login JWT에 Social Login까지 할 수 있는 모습을 보게 되었다.

아직도 많이 부족하지만 뭐든지 급하지 않게 차근차근 배워나간다면 못할 건 없다고 생각한다.

0개의 댓글