Dev

8. [SSO] Keycloak User Storage SPI 번역해보기

VIPeveloper 2020. 10. 12. 16:47
728x90
반응형

1. 서론

keycloak 기본 예제는 어느정도 할 수 있게 되었습니다. 하지만 실제 디비에 적용시키기엔 무리가 있다는 것을 알게 되었습니다.

 키클락에서 제공하는 칼럼 정보와 현재 회사에서 사용하고 있는 디비 칼럼구조와 다르기 때문에 이걸 어떻게 적용해야할지에 대한 고민을 많이 하게 되었고, 그러던 도중 User Storage SPI 라는 글이 있기에 번역해보면서 공부해보려 합니다. 해당 원문 링크는 여기에 있습니다.

2. 본론

사용자 스토리지 SPI를 사용하여 Red Hat Single Sign-On에 확장하여 외부 사용자 데이터베이스 및 인증 정보 저장소에 연결할 수 있습니다. 기본 제공 LDAP 및 ActiveDirectory 지원은 이 SPI를 실제 구현한 것입니다. Red Hat Single Sign-On은 로컬 데이터베이스를 사용하여 사용자를 생성, 업데이트 및 검색하고 자격 증명을 확인합니다. 그러나 현재 회사에는 Red Hat Single Sign-On의 데이터 모델과의 통합이 어려운 환경(RDBMS : 오라클, mySQL)으로 구성된 경우가 많습니다. 이러한 상황에서 애플리케이션 개발자는 사용자 스토리지 SPI 구현을 작성하여 Red Hat Single Sign-On이 사용자를 로그인하고 관리하는 데 사용하는 외부 사용자 저장소와 내부 사용자 개체 모델을 연결할 수 있습니다. 한마디로 SPI가 기존 DB의 유저 모델과 Keycloak을 연결해주는 Adapter 역할을 수행합니다.

 

Red Hat Single Sign-On 런타임에서 사용자가 로그인할 때와 같이 사용자를 검색해야 하는 경우 keycloak은 여러 단계를 수행하여 사용자를 찾습니다.

1. 먼저 사용자가 사용자 캐시에 있는지 확인하고, 사용자가 발견된 경우 메모리 내 표현을 사용합니다.

2. 그런 다음 Red Hat Single Sign-On 로컬 데이터베이스에서 사용자를 찾습니다.

3. 사용자를 찾을 수 없는 경우 사용자 스토리지 SPI 제공자 구현을 통해 사용자 쿼리를 수행하여 런타임이 찾고 있는 사용자를 반환할 때까지 사용자 쿼리를 수행합니다.

공급자는 외부 사용자 저장소에 사용자를 쿼리하고 사용자의 외부 데이터 표현을 Red Hat Single Sign-On 사용자 메타모델에 매핑합니다.

 

또한 사용자 스토리지 SPI 제공자를 구현하면 복잡한 기준 쿼리를 수행하거나, 사용자에 대한 CRUD 작업을 수행하거나, 자격 증명을 검증 및 관리하거나, 동시에 많은 사용자를 대량 업데이트할 수 있습니다. 외부 스토어의 기능에 따라 다릅니다.

사용자 스토리지 SPI 제공자 구현은 Java EE 구성 요소와 유사하게(흔히) 패키지 및 배포됩니다. 기본적으로 사용하도록 설정되어 있지 않지만, 관리 콘솔의 User Federation 탭에서 영역별로 사용하도록 설정하고 구성해야 합니다.

 

2.1. Provider Interfaces

사용자 스토리지 SPI를 구현하는 경우 Provider 클래스와 ProviderFactory 클래스를 정의해야 합니다. Provider 클래스 인스턴스는 ProviderFactory 에서 트랜잭션별로 생성됩니다. Provider 클래스는 사용자 조회를 포함한 기타 사용자 작업의 거의 모든 작업을 담당하고 있습니다. org.keycloak.storage를 구현해야 합니다.UserStorageProvider 인터페이스입니다.

package org.keycloak.storage;

public interface UserStorageProvider extends Provider {


    /**
     * Callback when a realm is removed.  Implement this if, for example, you want to do some
     * cleanup in your user storage when a realm is removed
     *
     * @param realm
     */
    default
    void preRemove(RealmModel realm) {

    }

    /**
     * Callback when a group is removed.  Allows you to do things like remove a user
     * group mapping in your external store if appropriate
     *
     * @param realm
     * @param group
     */
    default
    void preRemove(RealmModel realm, GroupModel group) {

    }

    /**
     * Callback when a role is removed.  Allows you to do things like remove a user
     * role mapping in your external store if appropriate

     * @param realm
     * @param role
     */
    default
    void preRemove(RealmModel realm, RoleModel role) {

    }

}

UserStorageProvider 인터페이스가 의외로 간단해보인다고 생각하실 수도 있습니다. 뒷부분에서 공급자 클래스가 사용할 사용자 통합의 핵심 부분을 지원하기 위한 다른 혼합 인터페이스가 나오게 됩니다.

UserStorageProvider 인스턴스는 트랜잭션당 한 번 생성됩니다. 트랜잭션이 완료되면 UserStorageProvider.close() 메서드가 호출되고 인스턴스가 수집됩니다. 인스턴스는 Provider Factory 에서 생성됩니다. Provider Factory 에서는 org.keycloak.storage를 구현합니다.

package org.keycloak.storage;

/**
 * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
 * @version $Revision: 1 $
 */
public interface UserStorageProviderFactory<T extends UserStorageProvider> extends ComponentFactory<T, UserStorageProvider> {

    /**
     * This is the name of the provider and will be shown in the admin console as an option.
     *
     * @return
     */
    @Override
    String getId();

    /**
     * called per Keycloak transaction.
     *
     * @param session
     * @param model
     * @return
     */
    T create(KeycloakSession session, ComponentModel model);
...
}

Provider factory 클래스는 UserStorageProviderFactory를 구현할 때 템플릿 매개 변수로 구체적인 공급자 클래스를 지정해야 합니다. 런타임에서 이 클래스의 기능(이 클래스가 구현하는 다른 인터페이스)을 검색하려면 이 클래스가 필수입니다. 예를 들어 공급자 클래스의 이름이 FileProvider인 경우 공장 클래스는 다음과 같이 표시됩니다.

public class FileProviderFactory implements UserStorageProviderFactory<FileProvider> {

    public String getId() { return "file-provider"; }

    public FileProvider create(KeycloakSession session, ComponentModel model) {
       ...
    }
}

getId() 메서드는 사용자 스토리지 Provider의 이름을 반환합니다(예제에서는 file-provider로 반환되는 것을 알 수 있습니다). 이 ID는 해당 realm에 대해 Provider를 접근 가능으로 설정하려는 경우 관리 콘솔의 사용자 연합 페이지에 표시됩니다.

create() 메서드는 공급자 클래스의 인스턴스를 할당하는 역할을 합니다. org.keycloak.models.KeycloakSession을 패키지로 가지고 있습니다. 이 개체를 사용하여 다른 정보 및 메타데이터를 검색할 수 있을 뿐 아니라, 런타임 내의 다양한 다른 구성 요소에 액세스할 수 있습니다. 메서드 내 ComponentModel model 매개 변수는 공급자가 특정 영역 내에서 활성화 및 구성된 방식을 나타냅니다. 이 파일에는 관리 콘솔을 통해 사용하도록 설정할 때 지정한 구성뿐만 아니라 사용하도록 설정된 공급자의 인스턴스 ID도 포함됩니다.

UserStorageProviderFactory에는 이 장 뒷부분에서 다룰 다른 기능도 있습니다.

 

2.2. Provider Capability Interfaces

UserStorageProvider 인터페이스를 가만히 살펴보면, 정작 사용자를 찾거나 관리하는 방법이 정의되어 있지 않은 것을 볼 수 있습니다. 이러한 방법은 실제로 외부 사용자 저장소가 제공하고 실행할 수 있는 기능 범위에 따라 다른 기능 인터페이스에서 정의됩니다. 예를 들어 일부 외부 저장소는 읽기 전용이며 간단한 쿼리 및 자격 증명 유효성 검사만 수행할 수 있습니다. 각 기능을 커스터마이징하여 구현할 수 있습니다.

SPI Description
org.keycloak.storage.user.UserLookupProvider 외부 사용자로 로그인 하고자 할 때 쓰이는 인터페이스.
모든 provider들이 이 인터페이스는 구현해야한다.
org.keycloak.storage.user.UserQueryProvider
하나 이상의 사용자를 찾는 데 사용되는 복잡한 쿼리를 정의합니다. 관리 콘솔에서 해당 사용자를 관리하려면 이 인터페이스를 구현해야 합니다.
org.keycloak.storage.user.UserRegistrationProvider 회원 가입 및 탈퇴를 위한 인터페이스입니다.
org.keycloak.storage.user.UserBulkUpdateProvider
해당 Provider가 사용자 집합의 대량 업데이트를 지원하는 경우 이 인터페이스를 구현합니다.
org.keycloak.credential.CredentialInputValidator
Provider가 비밀번호나, 다른 자격증명을 위해 쓰입니다.
org.keycloak.credential.CredentialInputUpdater
Provider가 비밀번호나, 다른 자격증명을 업데이트하기 위해 쓰입니다.

2.3. Model Interfaces

기능 인터페이스에 정의된 대부분의 메서드는 사용자 표시로 반환되거나 전달됩니다. 이러한 표현은 org.keycloak.models.UserModel 에 의해 정의된 인터페이스입니다. 유저모델 구현을 통해 Red Hat Single Sign-On에서 사용하는 외부 사용자 저장소와 사용자 메타 데이터 간의 매핑을 구현할 수 있습니다.

package org.keycloak.models;

public interface UserModel extends RoleMapperModel {
    String getId();

    String getUsername();
    void setUsername(String username);

    String getFirstName();
    void setFirstName(String firstName);

    String getLastName();
    void setLastName(String lastName);

    String getEmail();
    void setEmail(String email);
...
}

UserModel 구현체는 사용자 이름, 전자 메일, 역할 및 그룹 매핑과 같은 사용자 관련 메타데이터를 읽고 업데이트할 수 있는 액세스 권한을 제공합니다. 또한, org.keycloak.models 패키지에는 Red Hat Single Sign-On metatodel의 다른 부분을 나타내는 다른 모델 클래스(RealmModel, RoleModel, GroupModel 및 ClientModel)가 있습니다.

2.3.1 Storage Ids

UserModel의 가장 중요한 기능중 하나는 getId()입니다. UserModel을 구현하려면 user id format에 대해 알아야합니다. 형식은 반드시 아래의 형식으로 진행됩니다.

"f:" + component id + ":" + external id

 

Red Hat Single Sign-On 런타임은 사용자 ID를 기준으로 사용자를 검색해야 하는 경우가 많습니다. 사용자 ID에는 충분한 정보가 포함되어 있으므로 런타임에서 사용자를 찾기 위해 시스템의 모든 UserStorageProvider를 쿼리할 필요가 없습니다.

[component id]는 ComponentModel.getId()에서 반환된 ID입니다. ComponentModel은 공급자 클래스를 만들 때 매개 변수로 전달되므로 여기서 가져올 수 있습니다. [external id]는 공급자 클래스가 외부 저장소에서 사용자를 찾는 데 필요한 정보입니다. 사용자 이름 또는 uid인 경우가 많습니다. 예를 들어 아래과 같이 보일 수 있습니다.

f:332a234e31234:wburke

런타임에서 ID를 기준으로 조회하면 ID를 구문 분석하여 [component id]를 가져옵니다. [component id]는 사용자를 로드하는 데 원래 사용된 UserStorageProvider를 찾는 데 사용됩니다. 그런 다음 해당 공급자가 ID를 전달합니다. 공급자가 다시 ID를 구문 분석하여 [external id]를 가져오면 외부 사용자 저장소에서 사용자를 찾는 데 사용됩니다.

2.4. Packaging and Deployment

User Storage providers는 JBoss EAP 애플리케이션 서버에 배포하는 것과 동일한 방식으로 JAR로 패키지되어 Red Hat Single Sign-On 런타임에 배포되거나 배포되지 않습니다. JAR을 서버의 deploy/ 디렉토리에 직접 복사하거나 JBoss CLI를 사용하여 배포를 실행할 수 있습니다.

Red Hat Single Sign-On에서 제공자를 인식하려면 JAR: META-INF/services/org.keycloak.storage.UserStorageProviderFactory에 ProviderFactory 경로를 추가해줍니다.

2.5. Simple Read-Only, Lookup Example

사용자 스토리지 SPI 구현의 기본 사항을 설명하기 위해 간단한 예를 살펴보겠습니다. 이 장에서는 간단한 속성 파일에서 사용자를 검색하는 간단한 UserStorageProvider를 구현하는 방법에 대해 설명합니다. 속성 파일에는 사용자 이름 및 암호 정의가 포함되어 있으며 클래스 경로의 특정 위치에 하드코딩됩니다. 공급자는 ID와 사용자 이름으로 사용자를 조회할 수 있으며 비밀번호도 확인할 수 있습니다. 이 공급자에서 발생한 사용자는 읽기 전용입니다.

public class DemoUserStorageProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider, CredentialInputUpdater, CredentialInputValidator {
        ...
 }

DemoUserStorageProvider는 많은 인터페이스를 구현합니다. 먼저, SPI의 기본 요구사항인 UserStorageProvider를 구현합니다. 그리고 이 공급자가 저장한 사용자로 로그인할 수 있기 때문에 UserLookupProvider 인터페이스를 구현합니다. 로그인 화면을 사용하여 입력한 암호를 확인할 수 있기 때문에 CredentialInputValidator 인터페이스를 구현합니다. 속성 파일이 읽기 전용입니다. 사용자가 암호를 업데이트하려고 할 때 오류 조건을 게시하려고 하므로 CredentialInputUpdator를 구현합니다.


public class DemoUserStorageProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider, CredentialInputUpdater, CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final DemoRepository repository;
    private final Properties properties;
    private final Map<String,UserModel> loadedUsers = new HashMap<>();

    public DemoUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties, DemoRepository repository) {
        this.session = session;
        this.model = model;
        this.repository = repository;
        this.properties = properties;
    }
}

이 공급자 클래스의 생성자가 KeycloakSession, ComponentModel 및 속성 파일에 대한 참조를 저장합니다. 이 모든 것은 나중에 사용하겠습니다. 로드된 사용자의 지도도 있습니다. 사용자를 찾을 때마다 동일한 트랜잭션 내에서 사용자를 다시 만들지 않도록 이 맵에 해당 사용자를 저장합니다. 이는 많은 제공업체(즉, JPA와 통합되는 모든 제공업체)가 이 작업을 수행해야 할 것이므로 따르는 것이 좋습니다. 또한 공급자 클래스 인스턴스는 트랜잭션당 한 번 생성되며 트랜잭션이 완료된 후 닫힙니다.


2.5.1.1. UserLookupProvider Implementation

public class DemoUserStorageProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider, CredentialInputUpdater, CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final DemoRepository repository;
    private final Properties properties;
    private final Map<String,UserModel> loadedUsers = new HashMap<>();

    public DemoUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties, DemoRepository repository) {
        this.session = session;
        this.model = model;
        this.repository = repository;
        this.properties = properties;
    }

    
    @Override
    public UserModel getUserById(String id, RealmModel realm){
        StorageId storageId = new StorageId(id);
        String username = storageId.getExternalId();
        return getUserByUsername(username,realm);
    }
    
    @Override
    public UserModel getUserByUsername(String username,RealmModel realm){
        UserModel adapter = loadedUsers.get(username);
        if(adapter == null){
            String password = properties.getProperty(username);
            if(password != null){
                adapter = createAdapter(realm,username);
                loadedUsers.put(username,adapter);
            }
        }
        return adapter;
    }

    private UserModel createAdapter(RealmModel realm, String username){
        return new AbstractUserAdapter(session,realm,model){
            @Override
            public String getUsername(){
                return username;
            }
        };
    }


    @Override
    public UserModel getUserByEmail(String email, RealmModel realm) {
        return getUserByUsername(email, realm);
    }
}

getUserByUserName() 메서드는 사용자가 로그인할 때 Red Hat Single Sign-On 로그인 페이지에서 호출됩니다. 구현에서는 먼저 loadedUsers 맵을 확인하여 사용자가 이 트랜잭션 내에서 이미 로드되었는지 확인합니다. 로드되지 않은 경우 속성 파일에서 사용자 이름을 찾습니다. UserModel 구현이 있는 경우 나중에 참조할 수 있도록 loadedUsers에 저장하고 이 인스턴스를 반환합니다.

createAdapter() 메서드는 helper class org.keycloak.storage.adapter를 사용합니다.ObstractUserAdapter입니다. 사용자 모델에 대한 기본 구현을 제공합니다. 사용자의 사용자 이름을 [external id]로 사용하여 위에서 생성하였던 스토리지 ID 형식을 기반으로 사용자 ID를 자동으로 생성합니다.

"f:" + component id + ":" + username

ObstractUserAdapter의 모든 get 메서드는 null이거나 비어 있는 컬렉션을 반환합니다. 그러나 역할 및 그룹 매핑을 반환하는 메서드는 모든 사용자에 대해 영역에 대해 구성된 기본 역할 및 그룹을 반환합니다. 모든 ObstractUserAdapter 설정 방법은 org.keycloak.storage를 생성합니다.ReadOnlyException입니다. 따라서 관리 콘솔에서 사용자를 수정하려고 하면 오류가 발생합니다.

getUserById() 메서드는 org.keycloak.storage를 사용하여 id 매개 변수를 구문 분석합니다.StorageId 도우미 클래스입니다. StorageId.getExternalId() 메서드가 호출되어 ID 매개 변수에 포함된 사용자 이름을 가져옵니다. 그런 다음 메소드를 통해 사용자별 사용자 이름()을 가져오도록 위임합니다.

처음 회원가입 시, 이메일이 저장되지 않으므로 getUserByEmail() 메서드는 일단 null을 반환합니다.

2.5.1.2. CredentialInputValidator Implementation

이제 CredentialInputValidator를 구현해봅시다.


public class DemoUserStorageProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider, CredentialInputUpdater, CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final DemoRepository repository;
    private final Properties properties;
    private final Map<String,UserModel> loadedUsers = new HashMap<>();

    public DemoUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties, DemoRepository repository) {
        this.session = session;
        this.model = model;
        this.repository = repository;
        this.properties = properties;
    }

    
    @Override
    public UserModel getUserById(String id, RealmModel realm){
        StorageId storageId = new StorageId(id);
        String username = storageId.getExternalId();
        return getUserByUsername(username,realm);
    }
    
    @Override
    public UserModel getUserByUsername(String username,RealmModel realm){
        UserModel adapter = loadedUsers.get(username);
        if(adapter == null){
            String password = properties.getProperty(username);
            if(password != null){
                adapter = createAdapter(realm,username);
                loadedUsers.put(username,adapter);
            }
        }
        return adapter;
    }

    private UserModel createAdapter(RealmModel realm, String username){
        return new AbstractUserAdapter(session,realm,model){
            @Override
            public String getUsername(){
                return username;
            }
        };
    }


    @Override
    public UserModel getUserByEmail(String email, RealmModel realm) {
        return getUserByUsername(email, realm);
    }
    
    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
        String password = properties.getProperty(user.getUsername());
        return credentialType.equals(CredentialModel.PASSWORD) && password != null;
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return credentialType.equals(CredentialModel.PASSWORD);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;

        UserCredentialModel cred = (UserCredentialModel)input;
        String password = properties.getProperty(user.getUsername());
        if (password == null) return false;
        return password.equals(cred.getValue());
    }
}

isConfiguredFor() 메서드는 런타임에 의해 호출되어 사용자에 대해 특정 자격 증명 유형이 구성되어 있는지 확인합니다. 이 메서드는 사용자에게 암호가 설정되어 있는지 확인합니다.

supportCredentialType() 메서드는 특정 자격 증명 유형에 대해 유효성 검사가 지원되는지 여부를 반환합니다. 자격 증명 유형이 암호인지 확인합니다.

isValid() 메서드는 암호의 유효성을 검사하는 역할을 합니다. CredentialInput 매개 변수는 실제로 모든 자격 증명 유형에 대한 추상 인터페이스일 뿐입니다. 우리는 자격 증명 유형을 지원하고 해당 자격 증명 유형이 UserCredentialModel의 인스턴스인지 확인합니다. 사용자가 로그인 페이지를 통해 로그인하면 암호 입력의 일반 텍스트가 UserCredentialModel 인스턴스에 추가됩니다. isValid() 메서드는 속성 파일에 저장된 일반 텍스트 암호와 비교하여 이 값을 확인합니다. true 반환 값은 암호가 유효함을 의미합니다.

2.5.1.3. CredentialInputUpdater Implementation

앞에서 설명한 대로 이 예에서 CredentialInputUpdater 인터페이스를 구현하는 유일한 이유는 사용자 암호 수정을 금지하기 위한 것입니다. 그렇지 않으면 Red Hat Single Sign-On 로컬 스토리지에서 암호를 재정의할 수 있기 때문입니다. 이에 대해서는 이 장 뒷부분에서 자세히 알아보겠습니다.

public class DemoUserStorageProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider, CredentialInputUpdater, CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final DemoRepository repository;
    private final Properties properties;
    private final Map<String,UserModel> loadedUsers = new HashMap<>();

    public DemoUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties, DemoRepository repository) {
        this.session = session;
        this.model = model;
        this.repository = repository;
        this.properties = properties;
    }

    
    @Override
    public UserModel getUserById(String id, RealmModel realm){
        StorageId storageId = new StorageId(id);
        String username = storageId.getExternalId();
        return getUserByUsername(username,realm);
    }
    
    @Override
    public UserModel getUserByUsername(String username,RealmModel realm){
        UserModel adapter = loadedUsers.get(username);
        if(adapter == null){
            String password = properties.getProperty(username);
            if(password != null){
                adapter = createAdapter(realm,username);
                loadedUsers.put(username,adapter);
            }
        }
        return adapter;
    }

    private UserModel createAdapter(RealmModel realm, String username){
        return new AbstractUserAdapter(session,realm,model){
            @Override
            public String getUsername(){
                return username;
            }
        };
    }


    @Override
    public UserModel getUserByEmail(String email, RealmModel realm) {
        return getUserByUsername(email, realm);
    }
    
    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
        String password = properties.getProperty(user.getUsername());
        return credentialType.equals(CredentialModel.PASSWORD) && password != null;
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return credentialType.equals(CredentialModel.PASSWORD);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;

        UserCredentialModel cred = (UserCredentialModel)input;
        String password = properties.getProperty(user.getUsername());
        if (password == null) return false;
        return password.equals(cred.getValue());
    }
    
    
    
    @Override
    public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
        if (input.getType().equals(CredentialModel.PASSWORD)) throw new ReadOnlyException("user is read only for this update");

        return false;
    }

    @Override
    public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {

    }

    @Override
    public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
        return Collections.EMPTY_SET;
    }
}

updateCredential() 메서드는 인증 정보 유형이 암호인지 확인합니다. 이 경우 ReadOnlyException이 발생합니다.

3. 결론

다음 포스팅에서 이어서 작성하겠습니다.

 

728x90
반응형