본문 바로가기
공부/프로그래밍

[springboot, oauth2] 라인(LINE) 소셜 로그인 연동(jwt, jwkSetUri)

by demonic_ 2020. 3. 9.
반응형

소셜로그인이 필요해 Spring 에서 제공하는 oauth2 를 사용했다.

이 글은 그 과정에서 겪은 것들을 기록하는 것이다.

여기서는 라인로그인 기능을 이용하는 것이며 JWT를 이용해 토큰을 던져준다.

사용되는 알고리즘은 HS256인데 이 부분을 가장 애먹었다.(Spring oauth2 를 사용하면 기본 알고리즘은 RS256 이다)

우선 Line Developers 사이트에 방문하여 Products 를 생성한다.

 

생성한 후엔 Web App을 켜주고 콜백을 설정했다.

 

 

이제 Spring을 설정한다.

 

SpringBoot 버전은 2.2.5 를 사용했고 빌드 툴은 gradle을, 그리고 의존성을 다음과 같이 추가했다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

 

스프링 시큐리티에 다음의 설정을 사용한다

oauth2Login을 사용하게 하고, 로그인을 성공하면 /loginSuccess로 이동하도록 설정했다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .oauth2Login()
                .defaultSuccessUrl("/loginSuccess")
                ;
    }
}

 

로그인 성공 후 호출될 API를 생성한다.

@RestController
public class LoginController {

    @GetMapping("/loginSuccess")
    public Object getLoginInfo(OAuth2AuthenticationToken authentication) {
        System.out.println("authentication = " + authentication);
        return authentication.getPrincipal().getAttributes();
    }
}

 

 

그리고 화면(index.html)을 생성하고 링크를 연결했다.

oauth2를 사용하면 인증경로를 따로 설정하지 않는 한 기본값인 /oauth2/authorization/{registrationId} 를 사용한다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Oauth2 Login</title>
</head>
<body>
<a href="http://localhost:8080/oauth2/authorization/line">Line 로그인</a>
</body>
</html>

 

이 부분은 spring-security-oauth2-client 안에 존재하는 클래스 DefaultOAuth2AuthorizationRequestResolver.class 를 이용하는데 이 클래스를 살펴보면 다음의 내용이 주석되어 있다

/**
 * An implementation of an {@link OAuth2AuthorizationRequestResolver} that attempts to
 * resolve an {@link OAuth2AuthorizationRequest} from the provided {@code HttpServletRequest}
 * using the default request {@code URI} pattern {@code /oauth2/authorization/{registrationId}}.
 *
 * <p>
 * <b>NOTE:</b> The default base {@code URI} {@code /oauth2/authorization} may be overridden
 * via it's constructor {@link #DefaultOAuth2AuthorizationRequestResolver(ClientRegistrationRepository, String)}.
 *
 * @author Joe Grandja
 * @author Rob Winch
 * @author Eddú Meléndez
 * @author Mark Heckler
 * @since 5.1
 * @see OAuth2AuthorizationRequestResolver
 * @see OAuth2AuthorizationRequestRedirectFilter
 */
public final class DefaultOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
...

 

이대로 서버를 실행하면 다음의 에러가 발생한다.

Method springSecurityFilterChain in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration required a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' that could not be found.

ClientRegistrationRepository 가 등록되어 있지 않기 때문에 발생한 에러다 그래서 SecurityConfig 파일 안에 추가로 생성한다.

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {

        List<ClientRegistration> clientRegistrations = new ArrayList<>();


        String DEFAULT_LOGIN_REDIRECT_URL = "{baseUrl}/login/oauth2/code/{registrationId}";

        ClientRegistration.Builder lineBuilder = ClientRegistration.withRegistrationId("line");
        lineBuilder.clientAuthenticationMethod(ClientAuthenticationMethod.POST);
        lineBuilder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
        lineBuilder.redirectUriTemplate(DEFAULT_LOGIN_REDIRECT_URL);
        lineBuilder.authorizationUri("https://access.line.me/oauth2/v2.1/authorize");
        lineBuilder.tokenUri("https://api.line.me/oauth2/v2.1/token");
        lineBuilder.clientName("LINE");
        lineBuilder.scope("profile", "openid");
        lineBuilder.clientId("1653928900");
        lineBuilder.clientSecret("48b3367f319095fb37c7764e5cb293b6");

        clientRegistrations.add(lineBuilder.build());
        return new InMemoryClientRegistrationRepository(clientRegistrations);
    }

이렇게 생성하고 서버를 실행해 로그인을 시도하면 다음의 에러가 발생한다.

JwkSet URI를 등록하라고 하는데, 다른 블로그를 보면 이 부분을 "temp"등으로 하는경우가 있다.

물론 이렇게해도 된다. 그 API가 JWT를 사용하지 않는다면.

예를들면 카카오가 그렇다. 카카오는 JWT, JWK방식으로 주고받지 않기 때문이다.

그에반해 라인은 인증(authorization)를 하고나면 ID_TOKEN을 주는데 JWT방식을 준다.

JWT, JWK가 잘 이해되지 않는다면 하단에 걸어둔 링크를 보면 좋을듯 하다.

https://www.letmecompile.com/api-auth-jwt-jwk-explained/

 

API 서버 인증을 위한 JWT와 JWK 이해하기

쿠키(cookie)를 이용한 세션기반의 인증의 경우 특정 웹서버에서 세션 상태(session state)를 유지해야 하기 때문에 stateless 하지않다. 서버 로직이 Stateless가 아닌 경우 더 많은 요청을 처리하기 위해 동일한 서버의 숫자를 늘리는 스케일 아웃(scale out)에 적합하지 않다. 또한 도메인이 다른 서버에 대해서는 해당 세…

www.letmecompile.com

 

실제로 "temp"로 설정해 실행하면 다음과 같은 에러가 발생한다

 

 

그래서 임시로(실제로 이걸 검토하는지 모르겠다. 구글이나 아마존에서 제공하는 jwk 가 있는데 그런걸 등록해도 안되었다.) 등록해두었다.

...
        ClientRegistration.Builder lineBuilder = ClientRegistration.withRegistrationId("line");
        lineBuilder.clientAuthenticationMethod(ClientAuthenticationMethod.POST);
        lineBuilder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
        lineBuilder.redirectUriTemplate(DEFAULT_LOGIN_REDIRECT_URL);
        lineBuilder.authorizationUri("https://access.line.me/oauth2/v2.1/authorize");
        lineBuilder.tokenUri("https://api.line.me/oauth2/v2.1/token");
        lineBuilder.clientName("LINE");
        lineBuilder.scope("profile", "openid");
        lineBuilder.clientId("1653928900");
        lineBuilder.clientSecret("48b3367f319095fb37c7764e5cb293b6");
        lineBuilder.jwkSetUri("http://localhost:8080");  // 이 부분 추가

        clientRegistrations.add(lineBuilder.build());
...

 

이제 로그인을 해보면 아까와 다른 메세지가 보인다.

 

내용을 보면 알고리즘이 다르기 때문이라고 나온다. 처음에는 이 부분을 jwkSetUri에서 설정해야하는 줄 알고 엄청 삽질했다.

그런데 복호화 하는 부분을 따라가보니 그렇게 하지 않아도 가능한 방법이 있었다.

Spring Oauth2 Client에서는 RS256알고리즘을 기본으로 사용하는데 그에 반해 라인에서 사용하는 알고리즘은 HS256이라 문제가 발생한 것이다.

 

코드를 수정하기전에 라인의 로그인 결과를 좀더 살펴보자.

라인으로 로그인하고 나면 다음과 같이 형태로 리턴된다. 그중 id_token에 우리가 원하는 정보가 담겨있으며 JWT형태로 되어있다.

{
    "access_token": "bNl4YEFPI/hjFWhTqexp4MuEw5YPs...",
    "expires_in": 2592000,
    "id_token": "eyJhbGciOiJIUzI1NiJ9...",
    "refresh_token": "Aa1FdeggRhTnPNNpxr8p",
    "scope": "profile",
    "token_type": "Bearer"
}

라인 문서사이트에서는 다음과 같이 작성되어 있다.

You can use any JWT library or write your own code from scratch to validate ID tokens and obtain user profile information and email addresses.

 

문서에서는 JWT library 를 다음 사이트로 연결해 값을 확인할 수 있게 돕는다

https://jwt.io/#libraries-io

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

라인 문서에도 HS256알고리즘을 사용하고 있다고 설명되어 있다. 실제로 값을 가져다가 복호화를 해보면 header에 HS256 알고리즘을 사용하고 있음을 알 수 있었다.

 

그렇다면 실제로 복호화 하는 것은 어떤것일까? 컴파일로 하나하나 따라가보니 다음의 클래스들을 거치고 있었다.

OidcAuthorizationCodeAuthenticationProvider 클래스에서 복호화를 시도한다

private OidcIdToken createOidcToken(ClientRegistration clientRegistration, OAuth2AccessTokenResponse accessTokenResponse) {
	JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(clientRegistration);
	Jwt jwt;
	try {
		jwt = jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN));
	} catch (JwtException ex) {
		OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, ex.getMessage(), null);
		throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString(), ex);
	}
	OidcIdToken idToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims());
	return idToken;
}

 

여기서 하나 알게된 것은 ID_TOKEN이라는 값이 기본값에 속한다는 것을 알았다. 위 코드에서도 보면 get(OidcParameterNames.ID_TOKEN)으로 자연스럽게 값을 호출하고 있다.

 

decode를 할때 jwtDecoder를 호출하여 복호화를 하는데, 이때 JwtDecoder 라는 인터페이스를 이용해 NimbusJwtDecoder에 접근한다

 

디버깅 상태에서는 token의 값을 확인할 수 있는데 값이 잘 들어와 있는것을 확인할 수 있었다.

 

Jwt로 parse한 후에 들어있는 것을 확인해보면 다음의 값들이 들어있다.

 

사용되는 알고리즘은 HS256인걸 확인할 수 있었고 심지어 payload에서 복호화도 되어있다...

그런데 왜 에러가 나는 걸까?

 

createJwt 부분을 실행할 때 jwtProcessor 에서 등록되어 있는 jwsKey를 사용한다. 그런데 여기에 등록되는 것의 기본값이 RS256으로 구성되어 있다는 점이다. 좀더 상세히 뜯어보면 위에서 대충 등록했던 jwkSetUrl 도 보인다

 

이것을 기반으로 DefaultJWTProcessor 안 selectKeys 메서드에 비교를 시도한다. 이것 역시 인터페이스이기에 구현체를 따라가야 했는데, 도착해보니 JWSVerificationKeySelector 파일 내였다. 여기서 알고리즘을 비교하고 있었다.

 

알고리즘이 같지않으니 emptyList()를 반환하고, DefaultJWTProcessor.process 메서드 내 아래 조건에 걸려 NO_JWS_KEY_CANDIDATES_EXCEPTION 를 리턴했다.

...
		List<? extends Key> keyCandidates = selectKeys(signedJWT.getHeader(), claimsSet, context);

		if (keyCandidates == null || keyCandidates.isEmpty()) {
			throw NO_JWS_KEY_CANDIDATES_EXCEPTION;
		}
...

 

 

때문에 여기에 들어가는 알고리즘을 바꿔주는걸 찾아야 했다.

인터넷에 찾아보니 OidcIdTokenDecoderFactory 에서 OidcIdToken 서명 확인을 위해 JwtDecoder를 제공하는 것을 알았다.

여기서 기본 알고리즘을 RS256으로 설정하고 있었다. 클래스 내부를 보면 다음과 같이 설정되어 있다.

public final class OidcIdTokenDecoderFactory implements JwtDecoderFactory<ClientRegistration> {
...
	private Function<ClientRegistration, JwsAlgorithm> jwsAlgorithmResolver = clientRegistration -> SignatureAlgorithm.RS256;
...
}

 

이 부분을 스프링 Bean으로 별도 구현하여 HS256을 넣기로 했다.

이전에 수정한 SecurityConfig 파일에서 아래 부분을 추가한다.

    @Bean
    public JwtDecoderFactory<ClientRegistration> idTokenecoderFactory() {
        OidcIdTokenDecoderFactory idTokenDecoderFactory = new OidcIdTokenDecoderFactory();
        idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> MacAlgorithm.HS256);
        return idTokenDecoderFactory;
    }

 

이제 라인 로그인을 완료하면 문제없이 성공시 redirect 할 url이 호출되었다.

 

다만... 이상태면 GrantedAuthority 의 권한을 다음처럼 부여한다.

Granted Authorities: ROLE_USER, SCOPE_openid, SCOPE_profile

 

 

이중에 openid 와 profile 은 라인 로그인 팝업을 띄울때 scope에 추가한 것들이다.

이것 외에도 이 사이트에서만 써야하는 것이 필요하다면 OAuth2UserService 를 구현해야 한다.

(구현하지 않으면 oauth2-client 에서 제공하는 OidcUserService 를 쓴다.)

@Component
public class CustomOAuth2UserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        Assert.notNull(userRequest, "userRequest cannot be null");
        OidcUserInfo userInfo = null;
        Set<GrantedAuthority> authorities = setGrantedAuthorities(userRequest, userInfo);

        OidcUser user;

        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        if (StringUtils.hasText(userNameAttributeName)) {
            user = new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo, userNameAttributeName);
        } else {
            user = new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo);
        }

        return user;
    }

    private Set<GrantedAuthority> setGrantedAuthorities(OidcUserRequest userRequest, OidcUserInfo userInfo) {
        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        // 이부분에서 ROLE_USER 를 추가한다.
        authorities.add(new OidcUserAuthority(userRequest.getIdToken(), userInfo));
        // SCOPE_{}는 필요없으므로 추가하지 않는다
//        OAuth2AccessToken token = userRequest.getAccessToken();
//        for (String authority : token.getScopes()) {
//            authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
//        }

        authorities.add(new SimpleGrantedAuthority("ROLE_CUSTOMER"));

        return authorities;
    }
}

 

 

 

이후 로그인을 성공하고 나면 위의 클래스를 거친 후 /loginSuccess 로 이동한다.

로그를 확인해서 GrantedAuthority 권한을 확인하면 ROLE_USER와 추가한 ROLE_CUSTOMER 가 들어있음을 확인할 수 있다.

 

Granted Authorities: ROLE_CUSTOMER, ROLE_USER

 

 

관련 코드는 깃헙에 추가해 두었다.

https://github.com/lemontia/springOauth2Client-LINE/

 

lemontia/springOauth2Client-LINE

Social Login(LINE) Sample. Contribute to lemontia/springOauth2Client-LINE development by creating an account on GitHub.

github.com

 

페이스북이나 구글 연동은 spring security oauth2 client 에서 기본제공한다.(CommonOAuth2Provider)
관련 샘플은 아래 깃헙 참조
https://github.com/lemontia/SpringBoot2_oauth2

 

lemontia/SpringBoot2_oauth2

Contribute to lemontia/SpringBoot2_oauth2 development by creating an account on GitHub.

github.com

 

끝.

 

 

 

참고:

https://www.letmecompile.com/api-auth-jwt-jwk-explained/

 

API 서버 인증을 위한 JWT와 JWK 이해하기

쿠키(cookie)를 이용한 세션기반의 인증의 경우 특정 웹서버에서 세션 상태(session state)를 유지해야 하기 때문에 stateless 하지않다. 서버 로직이 Stateless가 아닌 경우 더 많은 요청을 처리하기 위해 동일한 서버의 숫자를 늘리는 스케일 아웃(scale out)에 적합하지 않다. 또한 도메인이 다른 서버에 대해서는 해당 세…

www.letmecompile.com


라인 WEB로그인 문서:
https://developers.line.biz/en/docs/line-login/integrate-line-login/#verify-id-token

 

Integrating LINE Login with your web app | LINE Developers

Once you run LINE Login from your web application, the cookie is saved under the domain name access.line.me. As long as the cookie is valid, the SSO screen is displayed for login in the same browser.

developers.line.biz

 

JWT 알고리즘 변경 관련:

https://s0docs0spring0io.icopy.site/spring-security/site/docs/current/reference/html5

 

Spring Security Reference 中文文档教程

Any Spring-EL functionality is available within the expression, so you can also access properties on the arguments. For example, if you wanted a particular method to only allow access to a user whose username matched that of the contact, you could write

s0docs0spring0io.icopy.site

 

반응형

댓글