[Keycloak] 로그인로직(UsernamePasswordForm) 커스터마이징

Posted by 김성철

Keycloak - 로그인 로직 커스터마이징

사용 이유

Keycloak 에서 그냥 암호화를 사용하면 잘됨...(https://github.com/l10178/keycloak-encryption-provider)  
근데 LDAP 연동한 키클락에서는 비밀번호가 틀렷다고 나옴  
그래서 LDAP 연동할떄 비밀번호를 보내는 애가 복호화 하기전에 이미 암호화된 값을 가지고 간다고 생각하여  
로그인 로직을 커스터마이징 하기로 함  

사용한 파일

https://github.com/keycloak/keycloak/tree/main/services  
	※ 위의 경로의 소스를 임포트해야 분석하기 편함  
  
https://github.com/keycloak/keycloak/tree/main/services/src/main/java/org/keycloak/authentication/authenticators/browser  
https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java  
https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordFormFactory.java  
  
위의 링크에 있는 UsernamePasswordForm.java , UsernamePasswordFormFactory.java 두개의 파일을 사용하였음  

프로젝트 URL

https://github.com/kkimsungchul/keycloak-CustomLoginForm  

참고한 프로젝트 URL

https://github.com/l10178/keycloak-encryption-provider  
  
위의 프로젝트를 내려받아서 구현하려는 클래스를 변경하여서 재구현 하였음  
Gradle 설정도 그렇고 뭔가 자꾸 꼬임. 그래서 위에 프로젝트를 그대로 내려받되  
패키지설정부터 다 다시하였음  

배포

gradlew 파일이 있는 프로젝트 최상단 경로에서 아래의 명령어 실행  
=================================================================================================================  
gradlew shadowJar  
=================================================================================================================  
  
명령어 실행 시 아래의 경로에 jar 파일이 생성됨  
=================================================================================================================  
custom-username-password-form\build\libs  
=================================================================================================================  

적용

- JAVA 빌드  
	cd custom-username-password-form  
	./gradlew shadowJar  
  
	빌드 후 "KEYCLOAK_HOME/standalone/deployments/" 경로에 복사  
  
- JS 빌드  
	cd password-encryption-provider-js  
	npm ci && npm run build  
  
	빌드 후 "KEYCLOAK_HOME/themes/base/login/resources/js/" 경로에 복사  
  
- JS 파일 설정  
	"KEYCLOAK_HOME/themes/base/login/theme.properties" 파일에 아래의 내용 추가  
	=================================================================================================================  
	scripts=js/password-encryption-provider.js  
	=================================================================================================================  

적용 - UI

1. 설치되어 있는 keycloak 를 실행  
	참고링크 : https://github.com/kkimsungchul/study/blob/master/Keycloak/%5BKeycloak%5D%20%EC%84%A4%EC%B9%98%20%EB%B0%8F%20%EC%84%B8%ED%8C%85.txt  
  
2. http://localhost:8080/auth/ 접속 후 로그인  
  
3. 적용할 realm 을 선택  
	본인은 Demo라는 이름으로 생성한 realm 을 선택함  
  
4. 좌측 Configure 탭의 Authentication 클릭  
  
5. Flows 에서 Browser 선택 후 copy 클릭  
  
6. 명칭 지정, 난 Copy of browser 로 했음  
  
7. 생성한 Copy of browser 선택  
  
8. 화면에 표시되는 표에서 Username Password Form 을 삭제  
  
9. Copy Of Browser Forms 의 우측에 Actions 클릭 후 Add execution 클릭  
  
10. 해당 화면에 표시되는 셀렉트 창에서 Custom Username Password Form  선택후  Save  
  
11. 적용할 client 의 setting 페이지의 제일 하단부분의 "Authentication Flow Overrides" 를 아래와 같이 설정  
	Browser Flow : Copy of browse  
	Direct Grant Flow : Decrypt password <- 이거는 아무거나 해도됨 지금이랑 상관없음  
  
	메뉴위치 :  
		Configure  
		-> Clients  
		-> my_client (내가 생성 및 적용할 클라이언트)  
		-> settings  
		-> Authentication Flow Overrides  
  
	또는 Browser Flow 를 공백으로 두고 Bindings 탭에서 Browser Flow  를 변경해도 됨  
  
12. 적용 완료 후 적용한 클라이언트로 로그인 시도  

CustomUsernamePasswordForm.java 클래스 구현

- action 메소드에 내용추가  
- transformPassword 메소드 추가  
- validateTimeout 메소드 추가  
- parseJweObject 메소드 추가  
- 상수로 선언된 애들의 필드를 찾아서 직접 입력해줌  
	USERNAME_HIDDEN , REGISTRATION_DISABLED , USER_SET_BEFORE_USERNAME_PASSWORD_AUTH 등과 같이 원본소스에 있는 상수들을  
	찾을수가 없다는 오류가 계속 나와서 직접 입력했음  
=================================================================================================================  
package com.sungchul.keycloak.spi;  
  
import com.nimbusds.jose.JOSEException;  
import com.nimbusds.jose.JWEObject;  
import com.nimbusds.jose.crypto.RSADecrypter;  
import org.jboss.logging.Logger;  
import org.jboss.resteasy.specimpl.MultivaluedMapImpl;  
import org.keycloak.authentication.AuthenticationFlowContext;  
import org.keycloak.authentication.AuthenticationFlowError;  
import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm;  
import org.keycloak.crypto.Algorithm;  
import org.keycloak.crypto.KeyUse;  
import org.keycloak.crypto.KeyWrapper;  
import org.keycloak.forms.login.LoginFormsProvider;  
import org.keycloak.models.KeycloakSession;  
import org.keycloak.models.RealmModel;  
import org.keycloak.models.UserModel;  
import org.keycloak.protocol.oidc.OIDCLoginProtocol;  
import org.keycloak.services.managers.AuthenticationManager;  
  
import javax.ws.rs.core.MultivaluedMap;  
import javax.ws.rs.core.Response;  
import java.security.PrivateKey;  
import java.text.ParseException;  
import java.time.ZonedDateTime;  
import java.time.temporal.ChronoUnit;  
import java.util.Map;  
  
public class CustomUsernamePasswordForm extends UsernamePasswordForm {  
  
	private static final Logger logger = Logger.getLogger(CustomUsernamePasswordForm.class);  
  
	@Override  
	public void action(AuthenticationFlowContext context) {  
		MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();  
		boolean timeCheck = transformPassword(context.getSession(),context.getRealm(),formData,context);  
		//화면단에 데이터 전송  
		//꺼내서 사용할때는  <input type="hidden" id="bb" value ="${(testData!'')}"> 이렇게 사용하면 됨  
		//context.form().setAttribute("testData","sample test data");  
  
		//클라이언트와 서버의 시간차이가 5분이상일경우(클라이언트가 5분 더 느릴경우) 오류 처리  
		if(!timeCheck){  
			context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,  
					context.form().setError("서버와 클라이언트의 시간차이가 5분이상일 경우 로그인이 제한됩니다.","Timestamp is to far in the past").createErrorPage(  
							Response.Status.INTERNAL_SERVER_ERROR  
					));  
  
//            Response challengeResponse = challenge(context,"서버와 클라이언트의 시간차이가 5분이상일 경우 로그인이 제한됩니다.", "loginTimeout");  
//            context.challenge(challengeResponse);  
  
			return;  
		}  
  
		if (formData.containsKey("cancel")) {  
			context.cancelLogin();  
			//아래코드 작동안함  
			//context.form().setAttribute("testData","testData11");  
			System.out.println("로그인 11");  
			return;  
		}  
		if (!validateForm(context, formData)){  
			//아래코드 작동안함  
			context.form().setAttribute("testData","testData22");  
			System.out.println("로그인 22");  
			return;  
		}  
		context.success();  
	}  
  
	protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {  
		return validateUserAndPassword(context, formData);  
	}  
  
	@Override  
	public void authenticate(AuthenticationFlowContext context) {  
		MultivaluedMap<String, String> formData = new MultivaluedMapImpl<>();  
		String loginHint = context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);  
  
		String rememberMeUsername = AuthenticationManager.getRememberMeUsername(context.getRealm(), context.getHttpRequest().getHttpHeaders());  
  
		if (context.getUser() != null) {  
			LoginFormsProvider form = context.form();  
			form.setAttribute("usernameHidden", true);  
			form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);  
			context.getAuthenticationSession().setAuthNote("USER_SET_BEFORE_USERNAME_PASSWORD_AUTH", "true");  
		} else {  
			context.getAuthenticationSession().removeAuthNote("USER_SET_BEFORE_USERNAME_PASSWORD_AUTH");  
			if (loginHint != null || rememberMeUsername != null) {  
				if (loginHint != null) {  
					formData.add(AuthenticationManager.FORM_USERNAME, loginHint);  
				} else {  
					formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername);  
					formData.add("rememberMe", "on");  
				}  
			}  
		}  
		Response challengeResponse = challenge(context, formData);  
		context.challenge(challengeResponse);  
	}  
  
	@Override  
	public boolean requiresUser() {  
		return false;  
	}  
  
	protected Response challenge(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {  
		LoginFormsProvider forms = context.form();  
  
		if (formData.size() > 0) forms.setFormData(formData);  
  
		return forms.createLoginUsernamePassword();  
	}  
  
	@Override  
	public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {  
		// never called  
		return true;  
	}  
  
	@Override  
	public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {  
		// never called  
	}  
  
	@Override  
	public void close() {  
  
	}  
  
	private boolean transformPassword(KeycloakSession session, RealmModel realm, MultivaluedMap<String, String> formData,AuthenticationFlowContext context) throws IllegalStateException {  
		// Get the default active RSA key  
		KeyWrapper activeRsaKey = session.keys().getActiveKey(realm, KeyUse.SIG, Algorithm.RS256);  
		// read Password from input data  
		String passwordJWE = formData.getFirst("password");  
		JWEObject jweObject = parseJweObject(activeRsaKey, passwordJWE);  
		Map<String, Object> jsonObject = jweObject.getPayload().toJSONObject();  
		// Validate timestamp, make sure time is not far in the pass.  
		boolean timeCheck =validateTimeout(jsonObject,context);  
		// Set cleartext password in inputData  
		formData.addFirst("password",(String) jsonObject.get("pwd"));  
		return timeCheck;  
	}  
  
	private boolean validateTimeout(Map<String, Object> jsonObject,AuthenticationFlowContext context) {  
		ZonedDateTime dateTime = ZonedDateTime.parse((String) jsonObject.get("timestamp"));  
		ZonedDateTime now = ZonedDateTime.now();  
  
		//시간차이 계산부분  
		if(true){  
		//if (ChronoUnit.MINUTES.between(dateTime, now) > 5) {  
			logger.warn("Timestamp is to far in the past.");  
			//throw new IllegalStateException("Timestamp is to far in the past.");  
			context.form().setAttribute("clientTimeError","clientTimeError");  
			return false;  
		}  
  
		return true;  
	}  
  
	private JWEObject parseJweObject(KeyWrapper activeRsaKey, String passwordJWE) {  
		try {  
			// Parse JWE  
			JWEObject jweObject = JWEObject.parse(passwordJWE);  
			// Decrypt password using private key  
			jweObject.decrypt(new RSADecrypter((PrivateKey) activeRsaKey.getPrivateKey()));  
			return jweObject;  
		} catch (ParseException | JOSEException e) {  
			throw new IllegalStateException(e);  
		}  
	}  
}  
  
=================================================================================================================  

CustomUsernamePasswordFormFactory.java 클래스 구현

아래의 파일에서는 getDisplayType() 메소드 부분만 수정하였고  
그외에는 상단에 PROVIDER_ID 부분과 UsernamePasswordForm 이부분을 수정하였음  
  
=================================================================================================================  
  
package com.sungchul.keycloak.spi;  
  
import org.keycloak.Config;  
import org.keycloak.authentication.Authenticator;  
import org.keycloak.authentication.AuthenticatorFactory;  
import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm;  
import org.keycloak.models.AuthenticationExecutionModel;  
import org.keycloak.models.KeycloakSession;  
import org.keycloak.models.KeycloakSessionFactory;  
import org.keycloak.models.credential.PasswordCredentialModel;  
import org.keycloak.provider.ProviderConfigProperty;  
  
import java.util.List;  
  
public class CustomUsernamePasswordFormFactory implements AuthenticatorFactory {  
  
	public static final String PROVIDER_ID = "custom password form";  
	public static final UsernamePasswordForm SINGLETON = new CustomUsernamePasswordForm();  
  
	@Override  
	public Authenticator create(KeycloakSession session) {  
		return SINGLETON;  
	}  
  
	@Override  
	public void init(Config.Scope config) {  
  
	}  
  
	@Override  
	public void postInit(KeycloakSessionFactory factory) {  
  
	}  
  
	@Override  
	public void close() {  
  
	}  
  
	@Override  
	public String getId() {  
		return PROVIDER_ID;  
	}  
  
	@Override  
	public String getReferenceCategory() {  
		return PasswordCredentialModel.TYPE;  
	}  
  
	@Override  
	public boolean isConfigurable() {  
		return false;  
	}  
	public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {  
			AuthenticationExecutionModel.Requirement.REQUIRED  
	};  
  
	@Override  
	public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {  
		return REQUIREMENT_CHOICES;  
	}  
  
	@Override  
	public String getDisplayType() {  
		return "Custom Username Password Form";  
	}  
  
	@Override  
	public String getHelpText() {  
		return "sungchul help Validates a username and password from login form.";  
	}  
  
	@Override  
	public List<ProviderConfigProperty> getConfigProperties() {  
		return null;  
	}  
  
	@Override  
	public boolean isUserSetupAllowed() {  
		return false;  
	}  
}  
  
=================================================================================================================