공부하고 기록하는, 경제학과 출신 개발자의 노트

프로그래밍/이것저것_개발일지

화상 모의면접 연습 플랫폼 개발 프로젝트 (2) - KeyCloak 활용해서 서비스 DB에 OAuth 인증 붙이기

inspirit941 2021. 4. 18. 18:35
반응형

Yapp 동아리의 개발 프로젝트였던 화상 모의면접 연습 플랫폼 "위더뷰" 개발에 백엔드 개발자로 중도 합류했다.
 
github.com/witherview/witherview_backend

witherview/witherview_backend

🎯 위더뷰 Backend. Contribute to witherview/witherview_backend development by creating an account on GitHub.

github.com

기본적으로는 자바 스프링부트를 사용하지만, WebRTC의 경우 Node JS를 사용하는 구조다. 프론트는 React 기반이다.
중도에 합류해서 기존 코드와 구조를 어떻게 분석했는지 / 어떻게 개선방안을 찾아갔는지 생각을 정리하는 용도의 포스트.
 


 
이 당시 서비스 백엔드는 서버 메모리에 세션을 저장하는 방식의 로그인을 사용하고 있었다.
Spring에서 세션 방식의 로그인 구현이 쉬운 건 맞지만, 세션 기반 방식에서 토큰 기반 방식으로 변경하려 했던 이유는 두 가지였다.

  • 서버 메모리에서 사용자의 세션을 관리하는 방식은 scale out을 시도하기 어려운 구조.
  • Stateful한 구조. 서버가 일시적으로 다운되면 로그인 정보가 초기화될 수 있음.

이외에도 찾아보면 토큰 기반 로그인이 좋은 이유가 여러 개 나오지만,
웹에서 보편적으로 쓰는 stateless 방식 대신 stateful을 유지해야 할 이유가 딱히 없기도 했다.
 

 
사용자 인증을 위한 서비스로는 Keycloak 오픈소스를 활용해서 별도의 인증서버를 구축하기로 했다.

  • Java based 프로젝트이므로, Spring으로 만든 우리 API 서버와 코드 레벨에서 로직을 연동하기 쉬울 것이라고 판단했음.
  • KeyCloak에서 제공하는 OAuth 기능을 활용할 수 있고, JWT 토큰도 발급받을 수 있음.
  • Storage Provider Interface를 사용하면, 별도의 DB migration 없이 KeyCloak 서버를 인증에 사용할 수 있음.

 


구상

udemy의 OAuth 2.0 in Spring Boot Application 강의 스크린샷

위의 스크린샷은 참고용이며, 실제로 구상한 flow는 스크린샷과 약간 다르다.

  • 서비스 API의 User 데이터베이스는 그대로 유지한 채,
    클라이언트가 로그인 요청을 Existing Spring Boot Web Service로 보낸다.
  • Spring Boot Web Server는 로그인 요청을 받으면, KeyCloak Server에 Authentication 요청을 보낸다.
  • KeyCloak Server는 ID와 Password의 Valid 여부를 체크해서, 정상일 경우 JWT 토큰을 서버로 반환한다.
  • 사용자가 JWT 토큰을 포함해서 API 서버에 request를 보내면,
    KeyCloak Server를 구성할 때 사용한 정보 (client-id, client-secret, realm 등)를 토대로 JWT 토큰을 파싱한다.

 

적용

package com.inspirit941.remoteuserstorageprovider;
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.credential.UserCredentialStore;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.adapter.AbstractUserAdapter;
import org.keycloak.storage.user.UserLookupProvider;
import java.util.stream.Collectors;
public class RemoteUserStorageProvider implements UserStorageProvider, UserLookupProvider, CredentialInputValidator {
private KeycloakSession session;
private ComponentModel model;
private UserApiService userApiService;
public RemoteUserStorageProvider(KeycloakSession session, ComponentModel model, UserApiService userApiService) {
this.session = session;
this.model = model;
this.userApiService = userApiService;
}
// UserStorageProvider 인터페이스만으로는 validate 같은 메소드를 override 형태로 제공하는 건 아님.
// UserLookUpProvider로 getUserById / Email 등등이 가능하고
// CredentialInputValidator는 valid 검증.
// 위 세 개는 keycloak이 사용자를 검증하기 위한 최소한의 인터페이스라고 보면 된다.
@Override
public void close() {}
@Override
public UserModel getUserById(String id, RealmModel realm) { return null; }
@Override
public UserModel getUserByUsername(String username, RealmModel realm) {
// RESTEasy에서 send request to remote Web Service -> fetch userDetails.
// 셋 중 하나라도 제대로 된 UserModel을 리턴한다면 로그인 성공으로 간주함
UserModel returnValue = null;
var user = userApiService.getUserDetails(username);
if (user != null) {
returnValue = createUserModel(username, realm);
}
return returnValue;
}
private UserModel createUserModel(String username, RealmModel realm) {
return new AbstractUserAdapter(session, realm, model) {
@Override
public String getUsername() {
return username;
}
};
}
@Override
public UserModel getUserByEmail(String email, RealmModel realm) {
return null;
}
@Override
public boolean supportsCredentialType(String credentialType) {
// 예시의 경우, credential 타입인 password를 지원하는지 확인할 용도
return PasswordCredentialModel.TYPE.equals(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
// credential Type이 given user를 위한 게 맞는지 확인할 용도
// 공식 docs 내용 그대로 복사
if (!supportsCredentialType(credentialType)) return false;
var result = getCredentialStore()
.getStoredCredentialsByTypeStream(realm, user, credentialType)
.collect(Collectors.toList()).isEmpty();
return !result;
}
private UserCredentialStore getCredentialStore() {
return session.userCredentialManager();
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
// 사용자가 제공한 패스워드가 DB에 저장된 패스워드와 일치하는지를 확인하는 keycloak 호출 메소드.
// 이 값이 true이면 user는 authenticated된 것임.
// send a request to our remoteService Endpoint -> validate the provided password
var result = userApiService.verifyUserPassword(user.getUsername(), credentialInput.getChallengeResponse());
// getUserChallengeResponse -> 패스워드를 리턴함
if (result == null) return false;
return result.isResult();
}
}

KeyCloak에서 사용할 RemoteUserStorageProvider 클래스를 생성하고, 세 개의 인터페이스의 구현체를 implement한다.

  • UserStorageProvider: KeyCloak의 Storage Provider Interface 구현을 위한 기본 인터페이스.
  • UserLookUpProvider: remote storage에서 userDetail을 조회하기 위한 용도
  • CredentialInputValidator: User Credential을 검증하기 위한 용도

 

package com.witherview.keycloak.oauth.remoteuserstorage;
import com.witherview.keycloak.oauth.account.AccountApiService;
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder;
import org.jboss.resteasy.client.jaxrs.ResteasyWebTarget;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.storage.UserStorageProviderFactory;
import org.springframework.beans.factory.annotation.Value;
public class RemoteUserStorageProviderFactory implements UserStorageProviderFactory<RemoteUserStorageProvider> {
// keycloak admin console에서 확인할 수 있는 값.
// Enable UserStorageProvider for the realm.
public static final String PROVIDER_NAME = "witherview-MySQL";
// @Value("${connect.server}")
private String serviceUri = "http://localhost:8080";
@Override
public RemoteUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new RemoteUserStorageProvider(session, model, buildHttpClient(serviceUri));
}
@Override
public String getId() { return PROVIDER_NAME; }
// External Web Service에 user 정보를 요청하기 위한 http Client.
private AccountApiService buildHttpClient(String uri) {
ResteasyClient client = new ResteasyClientBuilder().build();
ResteasyWebTarget target = client.target(uri);
return target.proxyBuilder(AccountApiService.class)
.classloader(AccountApiService.class.getClassLoader())
.build();
}
}

다음으로, RemoteUserStorageProviderFactory 클래스를 생성한다.
 
 

package com.witherview.keycloak.oauth.account;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
@Consumes(MediaType.APPLICATION_JSON)
public interface AccountApiService {
@GET
@Path("/oauth/user/{email}")
AccountDTO.ResponseLogin getUserDetails(@PathParam("email") String email);
@POST
@Path("/oauth/user")
boolean isValidPassword(AccountDTO.LoginValidateDTO login);
}
package com.witherview.keycloak.oauth.account;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
public class AccountDTO {
@Getter @Setter
public static class LoginValidateDTO {
private String userId;
private String password;
}
@Getter @Setter @JsonIgnoreProperties(ignoreUnknown = true)
public static class ResponseLogin {
private String id;
private String email;
private String name;
private String profileImg;
private String mainIndustry;
private String subIndustry;
private String mainJob;
private String subJob;
}
}
view raw AccountDTO.java hosted with ❤ by GitHub

RemoteUserStorageProviderFactory에서 External Web Service에 유저 정보 확인을 위해 호출할 API 명세를 지정한다.
여기서 호출할 API path와 payload는 Web Service Controller에 똑같이 지정한다.
https://github.com/witherview/witherview_backend/blob/bd21e0386c780fc1fb4e5dde1741292e2acfad6a/account-api/src/main/java/com/witherview/account/controller/AccountController.java#L227

GitHub - witherview/witherview_backend: 🎯 위더뷰 Backend

🎯 위더뷰 Backend. Contribute to witherview/witherview_backend development by creating an account on GitHub.

github.com

 
 
커스텀하게 생성한 UserStorageProvider를 KeyCloak에 등록하려면, jar 파일로 패키징해야 한다.
UserStorageProvider를 정의한 SpringBoot 프로젝트의 resource 하위 디렉토리에 META_INF/services 를 생성하고, 아래와 같이 파일을 생성한다.

com.witherview.keycloak.oauth.remoteuserstorage.RemoteUserStorageProviderFactory

UserStorageProviderFactory 클래스가 정의된 패키지명을 정확히 입력한 뒤,
UserStorageProvider SpringBoot 애플리케이션을 jar 파일로 빌드한다.
 

배포

https://www.keycloak.org/downloads.html 에서 keycloak zip파일을 다운받는다. 

downloads - Keycloak

Downloads 21.0.2 For a list of community maintained extensions check out the Extensions page. Server Quickstarts Client Adapters WildFly [DEPRECATED] <= 23 ZIP (sha1) TAR.GZ (sha1) JBoss EAP [DEPRECATED] 7 ZIP (sha1) TAR.GZ (sha1) JavaScript Node.js [DEPRE

www.keycloak.org

standalone/deployment 디렉토리에 빌드한 jar 파일을 복사한다.
 

UserStorageProvider가 정상적으로 설정되었다면,
keycloak 서버가 실행중인 상태에서 jar 파일을 복사해넣었을 때 HUD Deployment가 수행된 결과 파일이 생성된다.
위 스크린샷의 경우 *.jar.deployed 파일이 생성되면 정상적으로 해당 UserStorageProvider가 keycloak에 배포된 것.
 
 

keycloak UI에서 User Federation을 확인했을 때, Custom UserStorageProvider package명이 확인되고
Enable 설정하면 UserStorageProvider를 적용할 수 있다.
 


KeyCloak에서 생성하는 JWT Token에 custom Field를 추가할 수도 있다. 방법은 아래 링크를 참고했음
https://github.com/mschwartau/keycloak-custom-protocol-mapper-example/blob/master/README.md

GitHub - mschwartau/keycloak-custom-protocol-mapper-example: An example for building custom keycloak protocol mappers

An example for building custom keycloak protocol mappers - GitHub - mschwartau/keycloak-custom-protocol-mapper-example: An example for building custom keycloak protocol mappers

github.com

 
https://github.com/mschwartau/keycloak-custom-protocol-mapper-example/tree/master/protocol-mapper

GitHub - mschwartau/keycloak-custom-protocol-mapper-example: An example for building custom keycloak protocol mappers

An example for building custom keycloak protocol mappers - GitHub - mschwartau/keycloak-custom-protocol-mapper-example: An example for building custom keycloak protocol mappers

github.com

 
 

반응형