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 서버를 인증에 사용할 수 있음.
구상

위의 스크린샷은 참고용이며, 실제로 구상한 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; | |
} | |
} |
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
'프로그래밍 > 이것저것_개발일지' 카테고리의 다른 글
Paketo buildpack의 Stack Customization 테스트 기록 (0) | 2023.04.12 |
---|---|
Streamlink로 유튜브 멤버십 스트리밍 영상 다운로드하기 (2) | 2021.09.27 |
화상 모의면접 연습 플랫폼 개발 프로젝트 (1) - 채팅 DB 아키텍처 고민하기 (0) | 2021.02.20 |
Java WebSocket과 Stomp로 간단한 채팅프로그램 만들기 (0) | 2021.02.04 |
IBM Kubernetes Cluster에 SpringBoot Application 구동 실습하기 - 2. deploy (1) | 2020.11.10 |