Spring

[Spring] V1. OAuth2.0으로 소셜로그인 구현하기

chaego 2024. 3. 16. 16:25

소셜 로그인에서 사용되는 프로토콜인 OAuth에 대해 학습하고 Spring에서 구현해보자 ❕

 

 

 

1. OAuth (Open Authorization) ?


많은 사이트에서 소셜 계정을 기반으로 회원가입 & 로그인을 할 수 있는 기능이 있다. 소셜 로그인을 이용하면 번거롭게 개인 정보를 입력하고 회원가입을 할 필요가 없기 때문에 간편하게 로그인할 수 있다. 게다가 연동되는 웹 어플리케이션에서 제공하는 기능을 사용할 수도 있다.

이 때 사용되는 프로콜이 OAuth이다. OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 어플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는 접근 위임(Delegated Authorization)을 위한 표준 프로토콜이다.

 

 

OAuth 2.0은 OAuth 1.0의 구현이 복잡하다는 단점이 보완된 버전이며, 요즘 대부분의 서비스에서는 OAuth 2.0을 사용한다.

둘의 차이는 다음과 같다.

 

  • 보안 방식
    • OAuth 1.0은 서명된 요청을 사용해 보안 인증하고
    • OAuth 2.0은 HTTPS를 통해 통신하며 Access Token을 이용해 보안 인증한다.
  • 토큰 종류
    • OAuth 1.0은 Request Token & Access Token을 사용하고
    • OAuth 2.0은 Access Token & Refresh Token 을 사용한다.
  • 토큰 갱신
    • OAuth 1.0은 Access Token의 유효기간이 없고 갱신하는 것이 복잡했는데
    • OAuth 2.0은 Refresh Token을 이용해 Access Token을 쉽게 갱신할 수 있다.

 

 

 

2. OAuth 2.0 주체


  • Authorization Server (인가 서버)
    • 권한을 부여해주는 서버
    • Resource Owner는 이 서버로 id, pw를 넘겨 Authorization Code를 발급 받는다.
    • Client는 이 서버로 Authorization Code를 넘겨 Token을 발급 받는다.
  • Resource Server (API 서버)
    • 사용자의 개인정보를 가지고 있는 애플리케이션(naver, kakao, google 등)의 서버
    • Client는 Authorization Server에서 발급받은 Token을 이 서버로 넘겨 사용자 정보를 응답 받을 수 있다.
  • Resource Owner (사용자)
    • 웹 서비스를 이용하려는 유저, 사용자
  • Client (서비스 서버)
    • 내가 구현하는 애플리케이션 서버 (이 서버가 Resource Server 입장에서는 Client이므로)

 

 

 

3. 흐름


인증 방식에는 Password Grant, Client Credentials Grant, Authorization Code Grant, Implicit Grant 등 다양한 방법이 있다. 가장 대중적인 Authorization Code Grant 방식에 대해 정리해보려고 한다.

 

  1. 사용자(Resource Owner)가 서비스(Client)의 로그인 페이지에 접근해 소셜 로그인 버튼을 누르면 해당 플랫폼의 인가 서버(Authorization Server)에게 로그인 페이지를 요청한다.
  2. 인가 서버에서 사용자의 화면을 로그인 페이지로 redirect시킨다.
  3. 사용자가 id, pw를 입력해 로그인한다.
  4. 로그인 정보가 올바르다면 인가 서버는 인가 코드(Authorization code)를 Callback URL을 통해 사용자에게 전달한다.
  5. 사용자는 발급된 인가 코드를 그대로 Client에게 보낸다.
  6. Client는 인가 서버에게 인가 코드와 Client정보를 함께 보낸다.
  7. 인가 서버는 Client가 보낸 정보를 확인하고, 토큰(Access Token)을 발급한다.
  8. 토큰을 받은 Client는 리소스 서버(Resource Server)를 통해 사용자 정보를 조회하고, 조회된 결과를 이용해 Client 서버에서 회원가입 처리(DB에 저장) 후 로그인이 완료되었다고 응답한다.
    (이 때 Client DB에 저장되는 사용자의 정보는 pw를 포함하지 않으며 권한 설정으로 허락한 정보를 저장한다)

(4.에서 바로 Callback URL을 Client(내 서버)주소로 설정할 수 있는데, 이번에는 사용자에게 전달한 후 인가 코드를 Client에게 전달하는 방식으로 구현했다)

 

 

(Callback URL을 Client로 설정한 구현 방법은 다음 게시물에 정리했다)

 

V2. OAuth2.0으로 소셜로그인 구현하기 (spring-boot-starter-oauth2-client 사용)

OAuth 2.0으로 소셜로그인 구현하기 Version2 ❕ 소셜 로그인을 구현하기 위해 크게 1. 인가 요청 2. 토큰 요청 3. 사용자 정보 요청을 하는 과정이 필요하다. 지난번 소셜로그인을 구현했을 때는 인가

chchaego.tistory.com

 

 

 

4. 구현 - 네이버


네이버 로그인 API 명세 : https://developers.naver.com/products/login/api/api.md

 

 

애플리케이션 등록

먼저 소셜로그인 기능을 사용하려면 해당 서비스에서 내 애플리케이션을 등록해줘야 한다. 권한(scope) 설정과 서비스 URL, Callback URL을 설정해준다.

등록이 완료되면 Client IDClient Secret이 발급된다.

^Callback URL은 Client와 Resource Server 사이에 Authorization code를 전달하는 통로다.^ Resource Server는 Callback URL이 아닌 다른 URL에서 온 Authorization code는 믿지 않는다.

 

 

 

https://developers.naver.com/docs/login/api/api.md#2--api-%EA%B8%B0%EB%B3%B8-%EC%A0%95%EB%B3%B4

네이버 로그인 인증 요청 및 접근 토큰 발급 요청 시 필수 파라미터 값인 response_type, client_id, redirect_uri, state를 포함해 요청해야 한다.

 

 

Client 구현

1. 사용자(Resource Owner)가 서비스(Client)의 로그인 페이지에 접근해 소셜 로그인 버튼을 누르면 해당 플랫폼의 인가 서버(Authorization Server)에게 로그인 페이지를 요청한다.

  • NAVER_AUTH_PARAMS에 필수 파라미터 값들을 포함시켜 네이버 로그인 페이지를 요청했다.
  • redirect uri에 registrationId=naver을 파라미터로 넣어준 이유는 다른 소셜 로그인과 구분하기 위한 것이다.
/**
 * NAVER Social Login
 * 
 * Required Variable : response_type, client_id, redirect_uri, state
 */
const NAVER_CLIENT_ID = {CLIENT_ID};
const NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
const NAVER_AUTH_PARAMS = `?client_id=${NAVER_CLIENT_ID}
                                    &redirect_uri=http://localhost:3000/home?registrationId=naver
                                    &response_type=code
                                    &state=hihi`;

const SocialLoginComponent = (props) => {
    // 네이버 로그인 버튼
    return (
        <div className='social-button'>
          <a href={NAVER_AUTH_URL + NAVER_AUTH_PARAMS} target='_blank'>
             <img src="/assets/naver_login.png" width='40' />
          </a>
        </div>
    )
}

 

 

2. 인가 서버(Authorization Server)에서 사용자의 화면을 로그인 페이지로 redirect시킨다.

3. 사용자가 id, pw를 입력해 로그인한다.

 

4. 로그인 정보가 올바르다면 인가 서버는 인가 코드(Authorization code)를 Callback URL을 통해 사용자에게 전달한다.

  • 로그인이 완료되면 파라미터에 인가코드(code)가 담겨 Callback URL로 이동된다.

 

 

5. 사용자는 발급된 인가 코드를 그대로 Client에게 보낸다

  • __reqSocialLogin()를 실행해 Client에게 `registrationId`(제공 업체)와 `code`(인가 코드)를 전달한다.
/**
 * NAVER Social Login
 * 
 * Required Variable : response_type, client_id, redirect_uri, state
 */
const NAVER_CLIENT_ID = {CLIENT_ID};
const NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
const NAVER_AUTH_PARAMS = `?client_id=${NAVER_CLIENT_ID}
                                    &redirect_uri=http://localhost:3000/home?registrationId=naver
                                    &response_type=code
                                    &state=hihi`;

const SocialLoginComponent = (props) => {
    const navigate = useNavigate();
    const location = useLocation();
    const query = qs.parse(location.search);

    // 인가 코드를 그대로 Client에게 보낸다
    useEffect(() => {
        async function socialLoginAuth(registrationId, code) {
            navigate(qs.stringifyUrl({
                url: location.pathname,
                query: ''
            }),{
                replace: true
            });

            await __reqSocialLogin(registrationId, code);
        }

        if(!query || !query.registrationId || !query.code) {
            return;
        }
        
        socialLoginAuth(query.registrationId, query.code)
    }, []);

    // 네이버 로그인 버튼
    return (
        <div className='social-button'>
          <a href={NAVER_AUTH_URL + NAVER_AUTH_PARAMS} target='_blank'>
             <img src="/assets/naver_login.png" width='40' />
          </a>
        </div>
    )
}

 

이제 Client(내가 구현하는 애플리케이션 서버)에서 구현하는 일만 남았다.

 

 

Server 구현

먼저 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-security-oauth2-client'

spring-security-ouath2-client는 Spring Security에서 OAuth2.0 프로토콜을 사용해 인증처리를 하는 Client에게 편리한 기능을 제공한다.

(oauth2-client는 기본적으로 인가 요청, 토큰 요청, 사용자 정보 요청을 모두 Client가 담당하게 할 수 있다. 하지만 이번에는 Callback URL을 사용자에게 전달한 후 인가 요청의 결과로 받은 인가 코드를 Client에게 전달하는 방식으로 구현했다)

 

 

properties파일에 환경변수를 설정해준다.

# NAVER SOCIAL LOGIN SETTING
spring.security.oauth2.client.registration.naver.client-id={NAVER_CLIENT_ID}
spring.security.oauth2.client.registration.naver.client-secret={NAVER_CLIENT_SECRET}
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:3000/home

spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response

 

속성 설명
spring.security.oauth2.client.registration.{registrationId}.client-id 등록된 Client의 Client ID
spring.security.oauth2.client.registration.{registrationId}.client-secret 등록된 Client의 Client Secret
spring.security.oauth2.client.registration.{registrationId}.scope Client가 요청하는 권한 범위
spring.security.oauth2.client.registration.{registrationId}.authorization-grant-type 인증 방식
spring.security.oauth2.client.registration.{registrationId}.redirect-uri Redirect URI
spring.security.oauth2.client.provider.{registrationId}.authorization-uri 인증 요청을 위한 URI
spring.security.oauth2.client.provider.{registrationId}.token-uri 토큰 요청을 위한 URI
spring.security.oauth2.client.provider.{registrationId}.user-info-uri 사용자 정보 요청을 위한 URI
spring.security.oauth2.client.provider.{registrationId}.user-name-attribute 사용자 정보 요청 결과의 key값

 

이 외에도 더 많은 속성들을 설정해두고 사용할 수 있다.

 

 

6. Client는 인가 서버(Authorization Server)에게 인가 코드와 Client정보를 함께 보낸다.

7. 인가 서버는 Client가 보낸 정보를 확인하고, 토큰(Access Token)을 발급한다. 

@RestController
@RequestMapping("/api/social-login")
@RequiredArgsConstructor
public class SocialLoginController {
    private final SocialLoginService socialLoginService;

    @PostMapping("/{registrationId}")
    public ResponseEntity<?> socialLogin(HttpServletResponse response, @PathVariable(name = "registrationId") String registrationId, @RequestParam String code) {
        Message message = new Message();

        try {
            socialLoginService.socialLogin(response, registrationId, code);
            message.setStatus(HttpStatus.OK);
            message.setMessage("success");
        } catch (Exception e) {
            message.setStatus(HttpStatus.BAD_REQUEST);
            message.setMessage("error");
            message.setMemo(e.getMessage());
        }
        return new ResponseEntity<>(message, message.getStatus());
    }
}
@Service
@RequiredArgsConstructor
public class SocialLoginService {
     private final ClientRegistrationRepository clientRegistrationRepository;

     public void socialLogin(HttpServletResponse response, String registrationId, String code) throws IOException {
        // 넘어온 registrationId 값을 이용해 Provider 조회
        ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId);
        // 해당 provider의 token 요청 uri 조회
        String tokenRequestUri = clientRegistration.getProviderDetails().getTokenUri();

        // grant_type, client_id, client_secret, code, state 파라미터 설정
        MultiValueMap<String, String> parameter = new LinkedMultiValueMap<>();
        parameter.add("grant_type", "authorization_code");
        parameter.add("client_id", clientRegistration.getClientId());
        parameter.add("client_secret", clientRegistration.getClientSecret());
        parameter.add("code", code);
        parameter.add("state", "1234");

        // token 요청
        OAuthResponse.RequestToken tokenResponse = WebClient.create()
                .post()
                .uri(tokenRequestUri)
                .headers(header -> {
                    header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
                    header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
                })
                .bodyValue(parameter)
                .retrieve()
                .bodyToMono(OAuthResponse.RequestToken.class)
                .block();
        }
        
        ...
     }
}

 

  • token 요청의 결과로 다음과 같은 응답이 전달되는데, 여기서 사용할 token은 `access_token`이다. 나는 OAuthResponse.RequestToken 객체를 생성해 받아줬다.

public class OAuthResponse {

    @Getter
    @ToString
    @AllArgsConstructor
    @NoArgsConstructor
    public static class RequestToken {
        String access_token;
    }
}

 

 

8. 토큰을 받은 Client는 리소스 서버(Resource Server)를 통해 사용자 정보를 조회하고, 조회된 결과를 이용해 Client 서버에서 회원가입 처리(DB에 저장) 후 로그인이 완료되었다고 응답한다.
(이 때 Client DB에 저장되는 사용자의 정보는 pw를 포함하지 않으며 권한 설정으로 허락한 정보를 저장한다)

  • 토큰 요청으로 AccessToken을 받았다면 인증이 완료된 것이다.
  • 사용자 정보를 조회하는 API를 요청해 유저 정보를 조회하고 회원가입을 진행한다.
public class SocialLoginService {
    private final ClientRegistrationRepository clientRegistrationRepository;

        public void socialLogin(HttpServletResponse response, String registrationId, String code) throws IOException {
        // token 요청
        ...

        // 토큰을 발급받은 후 프로필 조회 api 요청
        String userInfoRequestUri = clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri();

        OAuthUserAttributes userAttributes = OAuthUserAttributes.of(registrationId, WebClient.create()
                .get()
                .uri(userInfoRequestUri)
                .headers(header -> header.setBearerAuth(tokenResponse.getAccess_token()))
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                .block());

        // 우리 플랫폼 회원가입 로직 실행
        ...
   }
}

 

  • 프로필 조회 API의 결과로 다음과 같은 응답이 전달되는데, 유저 scope로 요청했던 값들을 담기 위해 OAuthUserAttributes 객체를 생성해 받아줬다.

@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class OAuthUserAttributes {
    private String responseId;
    private String profileName;
    private String email;
    private String profileImagePath;

    public static OAuthUserAttributes of(String provider, Map<String, Object> attributes) {
        if (provider.equals("naver")) {
            return ofNaver(attributes);
        }
        return null;
    }

    private static OAuthUserAttributes ofNaver(Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return new OAuthUserAttributes(
                (String) response.get("id"),
                (String) response.get("name"),
                (String) response.get("email"),
                (String) response.get("profile_image"));
                
    }
}

 

그 다음에는 우리 플랫폼 회원가입 로직을 실행해 유저를 저장하고 AccessToken을 발급해 전달하면 된다.

 

 

 

5. 마무리


처음 소셜 로그인을 구현했을 때는 `spring-security-oauth2-client` 기능을 대부분 사용하지 않고 구현했었다. 스터디에서 소셜 로그인 기능을 구현하게 되면서 oauth2-client에서 필요한 기능들을 사용해 구현해봤다. 이전보다 코드도 간결해지고 어떤 요청을 하는지 눈에 잘보이게 됐다.

이번에는 인가 요청의 결과로 받은 인가 코드를 사용자단에서 받아 Client에게 요청하는 방식으로 구현했는데, oauth2-client는 기본적으로 인가 요청도 Client에서 담당하도록 할 수 있다. 다음에는 인가요청을 Client에게 넘겨서 구현해봐야겠다.

 

 

 

 

참고자료 😃

https://ko.wikipedia.org/wiki/OAuth

https://inpa.tistory.com/entry/WEB-📚-OAuth-20-개념-💯-정리

https://hudi.blog/oauth-2.0/

https://adjh54.tistory.com/221

https://docs.spring.io/spring-security/reference/reactive/oauth2/client/index.html