✍️ 개발 기록

[🎵MIML] Spotify 소셜 로그인 하기

ming412 2023. 10. 4. 14:25

프로젝트를 진행하면서 ...

 

1. Authorization Code Flow

스포티파이 소셜 로그인은 OAuth 2.0 서비스를 지원한다.

소셜 로그인의 flow는 아래 그림과 같다. (출처: 공식문서)

authorization code flow

- 사용자가 우리 앱에 로그인을 시도한다.

- 우리 앱은 사용자를 SPOTIFY ACCUNTS SERVICE로 리디렉션 시킨다.

- SPOTIFY ACCUNTS SERVICE는 사용자에게 읽을 데이터 범위(`scope`)에 대한 접근 승인 대화창을 띄운다. (만약 스포티파이에 사용자가 로그인되어 있지 않으면 먼저 로그인 후 접근 승인 대화창으로 넘어간다.)

- 사용자가 스포티파이 로그인에 성공하면 `redirect_uri` 주소로 리디렉션 되면서 주소창 query parameter에 `access_token`을 요청할 수 있는 `code`와 `state`가 넘어온다.

- 발급받은 `code`로 `access_token`을 요청한다.

    - 요청에 대한 응답으로 다음과 같은 정보가 JSON data로 반환된다.

{
   "access_token": "NgCXRK...MzYjw",
   "token_type": "Bearer",
   "scope": "user-read-private user-read-email",
   "expires_in": 3600,
   "refresh_token": "NgAagA...Um_SHo"
}

- 발급받은 `access_token`으로 SPOTIFY WEB API에 사용자 정보를 요청한다.

 

🔔 아래부터는 우리 앱에서 구현한 custom 로직이며, 이 글에서는 JWT 발급 로직 전까지만 다룬다.

 

- SPOTIFY WEB API로부터 전달받은 사용자 정보를 우리 앱 DB에 저장한다.

- 우리 앱에서 사용될 `access_token`(스포티파이의 `access_token`과 별개)과 `refresh_token`을 발급하여 클라이언트에게 전달한다.

 

2. 프로젝트 등록

- `Website`: http://localhost:8080

- `Redirect URI`: http://localhost:8080/login/oauth2/code/spotify

 

spotify for developers

🟠 Redirect URI
- 서비스에서 파라미터로 인증 정보가 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
- 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}

 

3. 코드 구현

application-oauth 등록

application.yml 파일이 있는 디렉토리에 `application-oauth.yml` 파일을 생성한 뒤 아래와 같이 spotify 앱 정보를 작성한다.

(❗️`application-oauth.yml` 파일은 반드시 `.gitignore`에 등록해야 한다.)

spring:
  security:
    oauth2:
      client:
        registration:
          spotify:
            client-id: {CLIENT_ID}
            client-secret: {CLIENT_SECRET}
            scope:
              - user-read-private
              - user-read-email
            client-name: Spotify
            authorization-grant-type: authorization_code
            redirect-uri: http://{DOMAIN}/login/oauth2/code/spotify

        provider:
          spotify:
            authorization-uri: https://accounts.spotify.com/authorize
            token-uri: https://accounts.spotify.com/api/token
            user-info-uri: https://api.spotify.com/v1/me
            user-name-attribute: id

 

- `client-id` 및 `client-secret`: OAuth2 클라이언트(=우리 앱)를 등록할 때 Spotify(공급자)로부터 발급 받은 클라이언트 ID와 클라이언트 시크릿. 이 정보를 사용해 클라이언트가 Spotify(공급자)와 통신할 수 있다. (Spotify 대시보드에서 확인)

- `scope`: OAuth2 클라이언트가 요청할 권한 범위 (공식 문서에서 확인)

- `client-name`: 클라이언트의 이름. Spotify OAuth2 클라이언트를 식별하는데 사용된다.

- `authorization-grant-type`: OAuth2 승인 유형

- `redirect-uri`: OAuth2 클라이언트가 사용자를 Spotify(공급자)의 인가 페이지로 리디렉션하고, 로그인 성공 또는 실패 후에 사용자를 다시 리디렉션할 때 사용되는 URI (Spotify 대시보드에 등록 필요)

- `authorization-uri`: OAuth2 인가 코드(`code`)를 요청하기 위한 인가 서버의 엔드포인트 URL

- `token-uri`: OAuth2 액세스 토큰(`access_token`)을 요청하기 위한 엔드포인트 URL

- `user-info-uri`: 로그인에 성공한 사용자의 정보를 가져오기 위한 엔드포인트 URL. Spotify(공급자)로 부터 사용자에 대한 정보를 가져온다.

- `user-name-attribute`: Spotify(공급자)로부터 반환되는 사용자 정보 중에서 사용자 이름 또는 ID와 같은 식별자 역할을 하는 속성 지정 (Spotify-`id` / Naver-`response` / Kakao-`id` 등..)

 

마지막으로 기본 설정인 `application.yml`에서 `application-oauth.yml`을 포함하도록 아래 코드를 추가하자.

spring:
  profiles:
    include:
      - oauth

 

SecurityConfig 설정

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ...
    http.authorizeRequests(authorizeRequests -> authorizeRequests
            .antMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(oauth2Login -> oauth2Login
            .loginPage("/auth/redirect-to-spotify")
            .userInfoEndpoint(userInfoEndpoint ->
                userInfoEndpoint.userService(customOauth2UserService)
            )
            .successHandler(oAuth2AuthenticationSuccessHandler)
            .failureHandler(oAuth2AuthenticationFailureHandler)
        )
        .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

 

 

OAuth2UserInfo 구현

공급자에게 받아올 사용자 정보를 정의한다.

공급자(ex. Spotify, Naver, Google, ..)마다 사용자 정보를 내려주는 응답이 다르기 때문에 세부 구현은 아래 `SpotifyUserInfo` 클래스에서 진행한다.

public abstract class OAuth2UserInfo {

	protected Map<String, Object> attributes;
	public OAuth2UserInfo(Map<String, Object> attributes) {
		this.attributes = attributes;
	}
	public abstract String getProviderId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id"
	public abstract OAuth2Provider getProvider();
	public abstract String getEmail();
	public abstract String getName();
	public abstract String getImage();
	public Map<String, Object> getAttributes() {
		return this.attributes;
	}
}

 

위에서 말한, 공급자마다 사용자 정보를 내려주는 응답이 다르다는 말은..

Spotify에서 사용자 이메일을 받아올 때 - `SpotifyUserInfo`

@Override
public String getEmail() {
    return (String) attributes.get("email");
}

Kakao에서 사용자 이메일을 받아올 때 - `KakaoUserInfo`

@Override
public String getEmail() {
    return Optional.ofNullable(attributes.get("kakao_account"))
        .map(account -> (String)((Map<String, Object>)account).get("email"))
        .orElse(null);
}

Naver에서 사용자 이메일을 받아올 때 - `NaverUserInfo`

@Override
public String getEmail() {
    return Optional.ofNullable(attributes.get("response"))
        .map(account -> (String)((Map<String, Object>)account).get("email"))
        .orElse(null);
}

 

SpotifyUserInfo 구현 (extends OAuth2UserInfo)

사용자 정보를 가져오는데 필요한 필드와 매핑되도록 한다.

public class SpotifyUserInfo extends OAuth2UserInfo {

	public SpotifyUserInfo(Map<String, Object> attributes) {
		super(attributes);
	}

	@Override
	public String getProviderId() {
		return String.valueOf(attributes.get("id"));
	}

	@Override
	public OAuth2Provider getProvider() {
		return OAuth2Provider.SPOTIFY;
	}

	@Override
	public String getEmail() {
		return (String) attributes.get("email");
	}

	@Override
	public String getName() {
		return (String) attributes.get("display_name");
	}

	@Override
	public String getImage() {
		return (String) attributes.get("image_url");
	}
}

 

이 외의 Spotify 로그인을 통해 받아올 수 있는 사용자 정보와 그 필드명은 아래를 참고하자.

https://developer.spotify.com/documentation/web-api/howtos/web-app-profile

 

Display your Spotify profile data in a web app | Spotify for Developers

Display your Spotify profile data in a web app This guide creates a simple client-side application that uses the Spotify Web API to get user profile data. We'll show both TypeScript and JavaScript code snippets, make sure to use the code that is correct fo

developer.spotify.com

 

CustomOAuth2User 구현 (extends DefaultOAuth2User)

OAuth2 로그인 후 사용자 정보를 저장하고 관리하기 위한 클래스이다.

/*
 *     OAuth2 로그인 직후 SecurityContext 의 Authentication Token 내에 저장될 객체
 *     로그인 이외의 요청시에는 사용되지 않으나 로그인 후 JWT 발행 등에 사용된다.
 */
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {

	private Member user;
	private String providerName; // spotify, kakao, naver
	private String accessToken; // spotify에서 내려준 access token

	/**
	 * OAuth2 사용자 정보, 사용자 계정 기반으로 CustomOAuth2User 객체를 생성할때 사용하는 생성자
	 * @param oAuth2UserInfo OAuth2 사용자 정보 객체
	 * @param user OAuth2 계정 정보를 기반으로 생성/조회한 사용자 계정 엔티티
	 */
	public CustomOAuth2User(OAuth2UserInfo oAuth2UserInfo, Member user, String accessToken) {
		super(List.of(new SimpleGrantedAuthority(user.getRole().name())), oAuth2UserInfo.getAttributes(),
			user.getId().getProvider().getAttributeKey());
		this.user = user;
		this.providerName = user.getId().getProvider().getProviderName();
		this.accessToken = accessToken;
	}

	// 시큐리티 컨텍스트 내의 인증 정보를 가져와 하는 작업을 수행할 경우 계정 식별자가 사용되도록 조치
	@Override
	public String getName() {
		return String.valueOf(user.getId());
	}

	public String getProvider() {
		return providerName;
	}

	public String getAccessToken() {
		return accessToken;
	}
}

 

CustomOAuth2UserService 구현 (extends DefaultOAuth2UserService)

공급자로부터 받아온 사용자 정보를 이용하여 `Member`를 조립한 뒤, 우리 앱 db에 저장하는 서비스 로직이다.

@Service
@AllArgsConstructor
public class CustomOauth2UserService extends DefaultOAuth2UserService {

	private final MemberRepository memberRepository;

	// userRequest 는 code를 받아서 accessToken을 응답 받은 객체
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2User oAuth2User = super.loadUser(userRequest);
		OAuth2UserInfo oAuth2UserInfo = getOAuth2UserInfo(userRequest, oAuth2User);
		return new CustomOAuth2User(
			oAuth2UserInfo,
			getOrCreateMember(oAuth2UserInfo),
			userRequest.getAccessToken().getTokenValue()
		);
	}

	private OAuth2UserInfo getOAuth2UserInfo(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
		String registrationId = userRequest.getClientRegistration().getRegistrationId();
		if (registrationId.equals(OAuth2Provider.SPOTIFY.getProviderName())) {
			return new SpotifyUserInfo(oAuth2User.getAttributes());
		}
		return null;
	}

	private Member getOrCreateMember(OAuth2UserInfo oAuth2UserInfo) {
		MemberId memberId = MemberId.builder()
			.provider(oAuth2UserInfo.getProvider())
			.providerId(oAuth2UserInfo.getProviderId())
			.build();
		Member existingMember = getExistingMember(memberId);
		return existingMember != null ? existingMember : createMember(oAuth2UserInfo, memberId);
	}

	private Member getExistingMember(MemberId memberId) {
		Optional<Member> oMember = memberRepository.findById(memberId);
		return oMember.orElse(null); // 회원이 없으면 null 반환
	}

	private Member createMember(OAuth2UserInfo oAuth2UserInfo, MemberId memberId) {
		Member member = Member.builder()
			.id(memberId)
			.name(oAuth2UserInfo.getName())
			.email(oAuth2UserInfo.getEmail())
			.role(RoleType.ROLE_USER)
			.image(oAuth2UserInfo.getImage())
			.build();
		return memberRepository.save(member);
	}
}

 

 

참고 자료

스포티파이 공식 예제

1. 스포티파이 소셜 로그인 구현하기

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

Spotify 소셜 로그인 하기 - 1편

[Spring Security] OAuth 구글 로그인하기

[Spring Security] 동작방법 및 Form, OAuth 로그인하기 (Feat. Thymeleaf 타임리프)