코딩은 내일부터

OAuth 그게 뭔데. (개념 및 구현) 본문

우아한 테크 코스(우테코)/우테코 공부

OAuth 그게 뭔데. (개념 및 구현)

zl존 비버 2023. 8. 16. 01:36
728x90

OAuth가 뭐야?

Oauth를 검색해보면 위키백과에는 이렇게 정의하고있는데…

음,,,

이렇게 보면 잘모르겠다..

 

 

 

예를 들어서 설명하면

비버라는 서비스를 이용하고있는데 서비스중에

카카오톡에 내 프로필사진을 가져오는 가능이있다고가정을 하겠다.

사용자는 비버한테 카카오톡 아이디와 패스워드를 입력하면

카카오톡의 프로필사진을 가져오는 흐름이다.

 

 

이때!

사용자 입장에서는 비버를 믿을 수 없고(카카오톡에 다른 정보를 빼오면 어떡하지?)

비버 입장에서도 이러한 정보를 가지고있는것이 부담이 될 수 있다.

(만약에 비버서비스를 해킹 당해서 사용자의 정보가 노출이 될 수 있으니까!!)

 

그리고 카카오톡 입장에서도 높은 보안으로 서비스를 만들어놨는데 이러한 정보가 노출되는게 싫을거같다.

 

그래서 도입할 수 있는게 OAuth이다.

쉽게말해서 인증은 사용자가 수행하고(로그인), 권한(프로필 사진)은 비버가 받을 수 있는 것 이다.

 

 

 

간략한 OAuth의 흐름

OAuth가 뭔지 알아봤으니 흐름을 살펴보겠다.

 

먼저 유저가 비버 서비스에 프로필을 바꿀려고 변경 버튼을 누른 상황이다.

 

 

이때 유저가 보고있는 페이지는 카카오로그인으로 넘어가기 때문에 비버한테 정보를 탈취할 경우는 없을 것이다.

 

 

 

 

 

 

 

다음으로 유저가 인증과정을 마치면 카카오는 프로필에 접근할 수 있는 권한을 비버한테 부여한다.

 

 

 

 

이때 카카오는 비버한테만! 권한을 부여한다.

그 이유는 권한을 탈취될 수 있는 범위를 최대한 좁혀서 보안의 이점을 얻기 위해서 이다.

 

 

 

OAuth랑 로그인이 무슨상관인데?

위에 OAuth흐름에서 사용자가 인증을 마치고 나서 인증한 플랫폼에서는 token을 발급해주는데 

이러한 토큰으로 사용자를 식별할 수 있는 profile을 받을 수 있다.

이러한 profile로 사용자를 식별하고

이미 회원가입한 사용자면 로그인을 회원가입을 하지않는 사용자는 회원가입 후 로그인을 시킬 수 있다.

 

 

OAuth 로그인 흐름

이제 OAuth을 구현해볼 차례다.

먼저 OAuth의 흐름이다!

에코 고마웡,,,

 OAuth 로그인 흐름
  1. 사용자가 로그인 버튼을 누른다.
  2. 프론트는 redirect uri를 백엔드에 넘긴다.
  3. 백엔드는 프론트로부터 받은 redirect uri와 환경변수를 통해 인가코드를 받는 uri만들어서 프론트한테 다시 던진다.                          (이유: 프론트에서 바로 만들어서 백엔드한테 요청할 수 있지만 하지 않는 이유는 redirect uri가 바뀔 때 백엔드(서버)의 코드가 바뀐다는 단점이 있어서 인가코드를 받기 위해서는 프론트에서 2번의 요청을 받는다!)
  4. 프론트는 3번에서 받은 URI를 통해 플랫폼에 로그인 요청을 한다.
  5. 사용자는 로그인을 한다.
  6. 플랫폼 에서는 Auth Code 발급
  7. 발급 받은 Auth Code와 redirect uri값을 백엔드에 넘겨 로그인 요청을한다.
  8. 백엔드는 플랫폼에 Token요청을하고 Token을 통해 또 다시 플랫폼한테 유저정보를 요청한다.
  9. 요청한 유저정보로 이미 회원가입한 사용자면 로그인을 회원가입을 하지않는 사용자는 회원가입 후 로그인을 시킨다.

후,,,,

이러한 흐름으로 구현해보겠다!

 

 

 

초기 설정

Kakao Developers에 접속한다.(https://developers.kakao.com/)

 


 

 

상당에 내 어플리케이션을 클릭한다.

 

 

 

 

 

 


 

 

 

애플리케이션 추가하기 클릭

 

 

 


 

 

 

                                                앱 이름사업자명을 입력 후 저장

 

 

 

 

 

 

 

 

 

 


 

 

 

 

저장버튼을 누르면 옆에 사진과 같은 페이지로 넘어가는데

 

여기서 REST API 는 앞으로 

client_id라고 부르게 될 아이다.

 

 

 

 

 

 

 

 

 


 

 

 

 

카카오 로그인으로 넘어와

활성 상태를 on으로 하고

Redirect URI를 클릭한다.

 

 

 

 

 

 


 

 

 

Redirect URI는 프론트엔드 URI를 입력해주세요.

저는 http://localhost:3000/oauth/kakao/redirected라고

설정하였습니다.

 

 

 

 

 

 


 

 

다음으로 코드 생성을 클릭 후

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

코드를 볼 수 있는데 얘 또한 secret-key라고 부르게 될 아이다.

 

다음으로 설정을 눌러서 사용함으로 바꿔주면 된다.

 

 

 

 


 

 

마지막으로 위에서 말한 사용자 정보의 어떤 권한을 받을지? 설정하는 페이지이다.

 

여기서 나는 닉네임, 프로필 사진,이메일을 받도록 설정했다.

 

 

 

 

 

 

 

 

 

 

 

진짜 구현!!

이제 진짜 구현을 해보겠다. (구현 순서  환경설정 -> Controller -> Service -> Infrastructure)

환경 설정

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.1.2'
	id 'io.spring.dependency-management' version '1.1.2'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'

	annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
	annotationProcessor 'org.projectlombok:lombok'

	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.rest-assured:rest-assured:5.3.0'

	runtimeOnly 'com.h2database:h2'

	compileOnly 'org.projectlombok:lombok'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

다음과 같이 환경 설정했다.(설명할게 딱히 없다....)


우리는 서비스를 만들면서 사용자의 편의를 위해 카카오로그인 뿐만 아니라 구글,깃헙 등등

여러개의 플랫폼으로 로그인을 할 수 있도록 여러 플랫폼을 추가할 수 도 있는 상황이다.

그래서 나는 추상화를 통해 확장성 코드로 구현해 나갈 것이다.

 

OAuthClient 추상화

import com.example.demo.domain.oauth.dto.UserInfo;
import com.example.demo.domain.SocialType;

public interface Oauth2Client {
    String createRedirectUri(final String redirectUri);

    String requestToken(final String authCode, final String redirectUri);

    UserInfo requestUserInfo(final String accessToken);

    SocialType getSocialType();
}

인터페이스로 공통적인 작업을 위와 같이 정의했다.

createRedirectUri는 인가 코드를 요청하는 Uri를 만드는 작업

requestToken은 인가 코드와 redirect uri를 통해 토큰을 발급받는 작업

requestUserInfo는 발급받은 토큰을 통해 유저의 profile을 받아오는 작업

마지막으로 추상화된 OAuthClient가 어떤 SocialType인지 알려주는 getSocialType으로 정의할 수 있다.

 

 

OAuthClient의 일급컬랙션

import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

public class Oauth2Clients {

    private final Map<SocialType, Oauth2Client> clients;

    public Oauth2Clients(Set<Oauth2Client> clients) {
        EnumMap<SocialType, Oauth2Client> mapping = new EnumMap<>(SocialType.class);
        clients.forEach(client -> mapping.put(client.getSocialType(), client));
        this.clients = mapping;
    }

    public String redirectUri(final SocialType socialType, final String redirectUri) {
        Oauth2Client client = getClient(socialType);
        return client.createRedirectUri(redirectUri);
    }

    public UserInfo requestUserInfo(final SocialType socialType, final String code, final String redirectUri) {
        Oauth2Client client = getClient(socialType);
        String accessToken = client.requestToken(code, redirectUri);
        return client.requestUserInfo(accessToken);
    }

    private Oauth2Client getClient(SocialType socialType) {
        return Optional.ofNullable(clients.get(socialType))
                .orElseThrow(() -> new IllegalArgumentException("해당 OAuth2 제공자는 지원되지 않습니다."));
    }
}

위에 코드는 추상화한 OAuthClient의 일급컬랙션으로 입력되는 SocialType을 통해 getClient메서드로 SocialType과 맞는 OAuthClient를 return하고 로직을 수행한다.

 

이러한 이유는 추상화를 통해 중복되는 코드를 줄이고 확장성을 고려해서 이렇게 코드를 구현했다.

 

 

AuthConfig

import com.example.demo.domain.oauth.Oauth2Client;
import com.example.demo.domain.oauth.Oauth2Clients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Set;

@Configuration
public class AuthConfig {

    @Bean
    public Oauth2Clients oAuth2Clients(Set<Oauth2Client> clients) {
        return new Oauth2Clients(clients);
    }
}

AuthConfig를 통해 빈으로 등록된 OAuthClient들의 집합을 Oauth2Clients라는 일급클래스의 생성자로 주입해주는 로직이다.

(이거 구현하면서 이런방법으로 빈 의집합을 주입해주는 법을 처음 알았다...)

 

 

KakaoOAuth2Client

이제 추상화는 끝났고 구현체를 만들어 볼 차례이다.

import com.example.demo.domain.oauth.Oauth2Client;
import com.example.demo.domain.SocialType;
import com.example.demo.domain.oauth.dto.UserInfo;
import org.springframework.stereotype.Component;

@Component
public class KakaoOauth2Client implements Oauth2Client {

    private final KakaoOauth2TokenClient tokenClient;
    private final KakaoOauth2UserInfoClient userInfoClient;

    public KakaoOauth2Client(final KakaoOauth2TokenClient tokenClient,
                             final KakaoOauth2UserInfoClient userInfoClient
    ) {
        this.tokenClient = tokenClient;
        this.userInfoClient = userInfoClient;
    }

    @Override
    public String createRedirectUri(final String redirectUri) {
        return tokenClient.createRedirectUri(redirectUri);
    }

    @Override
    public String requestToken(final String authCode,final String redirectUri) {
        return tokenClient.request(authCode,redirectUri);
    }

    @Override
    public UserInfo requestUserInfo(final String accessToken) {
        return userInfoClient.request(accessToken);
    }

    @Override
    public SocialType getSocialType() {
        return SocialType.KAKAO;
    }
}

KakaoOAuth2Client는 KakaoOauth2TokenClient,KakaoOauth2UserInfoClient를 필드로 가지고 있는 형태이다.

KakaoOAuth2Client클래스 내부에있는 메소드는 interface로 추상화 시킬때 설명했으니 넘어가겠다.

 

 

KakaoOauth2TokenClient,KakaoOauth2UserInfoClient

import com.example.demo.infrastructure.kakao.dto.KakaoTokenResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;

import java.nio.charset.StandardCharsets;
import java.util.Objects;

@Component
public class KakaoOauth2TokenClient {

    private static final String AUTHORIZE_URL = "https://kauth.kakao.com/oauth/authorize";
    private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token";
    private static final String GRANT_TYPE = "authorization_code";
    private static final String RESPONSE_TYPE = "code";

    private final String kakaoClientId;
    private final String kakaoClientSecret;
    private final WebClient webClient;

    public KakaoOauth2TokenClient(
            @Value("${kakao.client-id}") String kakaoClientId,
            @Value("${kakao.client-secret}") String kakaoClientSecret
    ) {
        this.kakaoClientId = kakaoClientId;
        this.kakaoClientSecret = kakaoClientSecret;
        this.webClient = WebClient.create();
    }

    public String createRedirectUri(final String redirectUri) {
        return UriComponentsBuilder.fromUriString(AUTHORIZE_URL)
                .queryParam("client_id", kakaoClientId)
                .queryParam("redirect_uri", redirectUri)
                .queryParam("response_type", RESPONSE_TYPE)
                .build()
                .toUriString();
    }

    public String request(final String authCode,final String redirectUri) {
        final BodyInserters.FormInserter<String> bodyForm = BodyInserters.fromFormData("grant_type", GRANT_TYPE)
                .with("client_id", kakaoClientId)
                .with("redirect_uri", redirectUri)
                .with("code", authCode)
                .with("client_secret", kakaoClientSecret);

        final KakaoTokenResponse kakaoTokenResponse = webClient.post()
                .uri(TOKEN_URL)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .acceptCharset(StandardCharsets.UTF_8)
                .body(bodyForm)
                .retrieve()
                .bodyToMono(KakaoTokenResponse.class)
                .block();
        return Objects.requireNonNull(kakaoTokenResponse).access_token();
    }
}
import com.example.demo.infrastructure.kakao.dto.KakaoProfileResponse;
import com.example.demo.domain.oauth.dto.UserInfo;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class KakaoOauth2UserInfoClient {

    private static final String PROFILE_URL = "https://kapi.kakao.com/v2/user/me";
    private final WebClient webClient;

    public KakaoOauth2UserInfoClient() {
        this.webClient = WebClient.create();
    }

    public UserInfo request(final String accessToken) {
        final KakaoProfileResponse kakaoProfileResponse = webClient.get()
                .uri(PROFILE_URL)
                .header("Authorization", "Bearer " + accessToken)
                .retrieve()
                .bodyToMono(KakaoProfileResponse.class)
                .block();
        return kakaoProfileResponse.toUserInfo();
    }
}

위 두 클래스는 카카오한테 API를 직접 날리는 클래스다.

 

아래 페이지로 카카오 API요청 형식을 자세하게 볼 수 있다.

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

 

 

OAuthService

import com.example.demo.domain.SocialType;
import com.example.demo.domain.oauth.dto.UserInfo;
import com.example.demo.domain.member.Member;
import com.example.demo.domain.member.MemberRepository;
import com.example.demo.domain.oauth.Oauth2Clients;
import com.example.demo.domain.token.JwtTokenProvider;
import com.example.demo.domain.token.RefreshToken;
import com.example.demo.domain.token.TokenRepository;
import com.example.demo.presentation.dto.LoginResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OAuthService {

    private final Oauth2Clients oauth2Clients;
    private final MemberRepository memberRepository;
    private final TokenRepository tokenRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public String createAuthorizeRedirectUri(final SocialType socialType, final String redirectUri) {
        return oauth2Clients.redirectUri(socialType, redirectUri);
    }

    public LoginResponse login(final SocialType socialType, final String code, final String redirectUri) {
        final UserInfo userInfo = oauth2Clients.requestUserInfo(
                socialType,
                code,
                redirectUri
        );

        return memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType())
                .map(member -> LoginResponse.logIn(createTokens(member)))
                .orElseGet(() -> LoginResponse.signUp(createTokens(signUp(userInfo))));
    }

    private Member signUp(final UserInfo userInfo) {
        return memberRepository.save(userInfo.toMember());
    }

    private Tokens createTokens(final Member member) {
        final String accessToken = jwtTokenProvider.createAccessToken(member.getId());
        final String refreshToken = jwtTokenProvider.createRefreshToken(member.getId());

        synchronizeRefreshToken(member, refreshToken);

        return new Tokens(member.getId(), accessToken, refreshToken);
    }

    private void synchronizeRefreshToken(final Member member, final String refreshToken) {
        tokenRepository.findByMemberId(member.getId())
                .ifPresentOrElse(
                        token -> token.update(refreshToken),
                        () -> tokenRepository.save(new RefreshToken(refreshToken, member))
                );
    }
}

OAuthService는 위에서 설명한 로직들이 수행한 후 회원가입 및 토큰 발급 후 LoginResponse를 통해 로그인된 회원정보와 토큰을 Controller로 던집니다.

 

OAuthController

import com.example.demo.domain.SocialType;
import com.example.demo.presentation.dto.LoginResponse;
import com.example.demo.application.OAuthService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@RequiredArgsConstructor
@RequestMapping("/oauth")
@RestController
public class OAuthController {

    private final OAuthService oathService;

    @GetMapping("/{socialType}")
    public ResponseEntity<Void> oathRedirectUri(
            @PathVariable SocialType socialType,
            @RequestParam final String redirectUri,
            HttpServletResponse response
    ) throws IOException {
        final String authorizeRedirectUri = oathService.createAuthorizeRedirectUri(socialType, redirectUri);
        response.sendRedirect(authorizeRedirectUri);
        return ResponseEntity.ok().build();
    }

    @GetMapping("/login/{socialType}")
    ResponseEntity<LoginResponse> login(
            @PathVariable SocialType socialType,
            @RequestParam("code") String code,
            @RequestParam("redirectUri") String redirectUri
    ) {
        LoginResponse response = oathService.login(socialType, code, redirectUri);
        return ResponseEntity.ok(response);
    }
}

마지막으로 OAuthController에서는 프론트에서 /oauth+ /원하는 플랫폼 +?파라미터로 redirectUri를 전달받으면

oathRedirectUri메소드의 내부동작으로 인가 코드 URI가 return되고

 

이 URi를 통해 프론트에서 /login/{socialType}을 요청하면 위에 설명했던 OAuth로직이 실행되서 LoginResponse를 return하게 된다.

 

 

 

 

 

프론트 코드는 말랑 블로그를 참고해서 구현하면 될거같습니다~

 

 

이상으로 OAuth였습니다!

github :https://github.com/ingpyo/login