소셜로그인이 필요해 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/
실제로 "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 를 다음 사이트로 연결해 값을 확인할 수 있게 돕는다
라인 문서에도 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/
페이스북이나 구글 연동은 spring security oauth2 client 에서 기본제공한다.(CommonOAuth2Provider)
관련 샘플은 아래 깃헙 참조
https://github.com/lemontia/SpringBoot2_oauth2
끝.
참고:
https://www.letmecompile.com/api-auth-jwt-jwk-explained/
라인 WEB로그인 문서:
https://developers.line.biz/en/docs/line-login/integrate-line-login/#verify-id-token
JWT 알고리즘 변경 관련:
https://s0docs0spring0io.icopy.site/spring-security/site/docs/current/reference/html5
'공부 > 프로그래밍' 카테고리의 다른 글
[spring] 테스트 중 Unable to initialize 'javax.el.ExpressionFactory' 해결하기 - validator 2.0.0(JSR-380) 적용 (0) | 2020.03.17 |
---|---|
[react] 다국어 처리(react-i18next) 적용하기 (1) | 2020.03.14 |
[spring, axios] Content-Type 을 json 또는 application/x-www-form-urlencoded 로 전송 테스트 (0) | 2020.03.04 |
[dbms] where에 In 절 사용 시 알아두면 좋은 것 (0) | 2020.02.27 |
[mysql] 조회, Index냐 Full Scan이냐 (2) | 2020.02.21 |
댓글