diff --git a/pom.xml b/pom.xml index 9f53eb6..fbfc85d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.keycloak keycloak-services-social-weixin - 0.0.10 + 0.0.19 Keycloak Services Social WeiXin diff --git a/src/main/java/org/keycloak/social/weixin/Util.java b/src/main/java/org/keycloak/social/weixin/Util.java new file mode 100644 index 0000000..9af741c --- /dev/null +++ b/src/main/java/org/keycloak/social/weixin/Util.java @@ -0,0 +1,25 @@ +package org.keycloak.social.weixin; + +import java.lang.reflect.Field; + +public class Util { + public static String inspect(String varName, Object thing) { + StringBuilder sb = new StringBuilder(); + + sb.append(varName).append(" >>>").append("\n"); + for (Field field : thing.getClass().getDeclaredFields()) { + field.setAccessible(true); + String name = field.getName(); + Object value = null; + try { + value = field.get(thing); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + sb.append("\t").append(name).append(": ").append(value).append("\n"); + } + sb.append(varName).append(" <<<").append("\n"); + + return sb.toString(); + } +} diff --git a/src/main/java/org/keycloak/social/weixin/WeiXinIdentityBrokerService.java b/src/main/java/org/keycloak/social/weixin/WeiXinIdentityBrokerService.java new file mode 100644 index 0000000..e0f2b23 --- /dev/null +++ b/src/main/java/org/keycloak/social/weixin/WeiXinIdentityBrokerService.java @@ -0,0 +1,688 @@ +package org.keycloak.social.weixin; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.OAuthErrorException; +import org.keycloak.authentication.AuthenticationProcessor; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.broker.util.PostBrokerLoginConstants; +import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; +import org.keycloak.broker.provider.*; +import org.keycloak.broker.provider.util.IdentityBrokerState; +import org.keycloak.common.ClientConnection; +import org.keycloak.common.util.ObjectUtil; +import org.keycloak.common.util.Time; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.AccountRoles; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.services.ErrorPage; +import org.keycloak.services.ErrorPageException; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.managers.BruteForceProtector; +import org.keycloak.services.managers.ClientSessionCode; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.resources.IdentityBrokerService; +import org.keycloak.services.resources.LoginActionsService; +import org.keycloak.services.resources.SessionCodeChecks; +import org.keycloak.services.resources.account.AccountFormService; +import org.keycloak.services.util.BrowserHistoryHelper; +import org.keycloak.services.validation.Validation; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.util.JsonSerialization; + +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import java.io.IOException; +import java.net.URI; +import java.util.Set; + +public class WeiXinIdentityBrokerService implements IdentityProvider.AuthenticationCallback { + private final RealmModel realmModel; + private static final Logger logger = Logger.getLogger(IdentityBrokerService.class); + private static final String LINKING_IDENTITY_PROVIDER = "LINKING_IDENTITY_PROVIDER"; + @Context + private KeycloakSession session; + + @Context + private HttpRequest request; + + @Context + private ClientConnection clientConnection; + + private EventBuilder event; + + + @Context + private HttpHeaders headers; + + public void init(KeycloakSession session, ClientConnection clientConnection, HttpHeaders headers, EventBuilder event, HttpRequest request) { + if (session != null) { + this.session = session; + } + + if (clientConnection != null) { + this.clientConnection = clientConnection; + } + + if (headers != null) { + this.headers = headers; + } + + if (request != null) { + this.request = request; + }else{ + + } + + logger.info("initializing ... realModel = " + Util.inspect("realmModel", realmModel)); + Util.inspect("session", this.session); + Util.inspect("clientConnection", this.clientConnection); + + if (event != null) { + this.event = event.event(EventType.IDENTITY_PROVIDER_LOGIN); + } else { + this.event = + new EventBuilder(this.realmModel, this.session, this.clientConnection).event(EventType.IDENTITY_PROVIDER_LOGIN); + } + } + + public WeiXinIdentityBrokerService(RealmModel realmModel) { + if (realmModel == null) { + throw new IllegalArgumentException("Realm can not be null."); + } + this.realmModel = realmModel; + } + + private Response redirectToErrorPage(Response.Status status, String message, Object... parameters) { + return redirectToErrorPage(null, status, message, null, parameters); + } + + private Response redirectToErrorPage(AuthenticationSessionModel authSession, Response.Status status, String message, Object... parameters) { + return redirectToErrorPage(authSession, status, message, null, parameters); + } + + private void fireErrorEvent(String message) { + fireErrorEvent(message, null); + } + + private void rollback() { + if (this.session.getTransactionManager().isActive()) { + this.session.getTransactionManager().rollback(); + } + } + + private void fireErrorEvent(String message, Throwable throwable) { + if (!this.event.getEvent().getType().toString().endsWith("_ERROR")) { + boolean newTransaction = !this.session.getTransactionManager().isActive(); + + try { + if (newTransaction) { + this.session.getTransactionManager().begin(); + } + + this.event.error(message); + + if (newTransaction) { + this.session.getTransactionManager().commit(); + } + } catch (Exception e) { + ServicesLogger.LOGGER.couldNotFireEvent(e); + rollback(); + } + } + + if (throwable != null) { + logger.error(message, throwable); + } else { + logger.error(message); + } + } + + private Response redirectToAccountErrorPage(AuthenticationSessionModel authSession, String message, Object... parameters) { + fireErrorEvent(message); + + FormMessage errorMessage = new FormMessage(message, parameters); + try { + String serializedError = JsonSerialization.writeValueAsString(errorMessage); + authSession.setAuthNote(AccountFormService.ACCOUNT_MGMT_FORWARDED_ERROR_NOTE, serializedError); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + + URI accountServiceUri = UriBuilder.fromUri(authSession.getRedirectUri()).queryParam(Constants.TAB_ID, authSession.getTabId()).build(); + return Response.status(302).location(accountServiceUri).build(); + } + + private Response checkAccountManagementFailedLinking(AuthenticationSessionModel authSession, String error, Object... parameters) { + UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession); + if (userSession != null && authSession.getClient() != null && authSession.getClient().getClientId().equals(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)) { + + this.event.event(EventType.FEDERATED_IDENTITY_LINK); + UserModel user = userSession.getUser(); + this.event.user(user); + this.event.detail(Details.USERNAME, user.getUsername()); + + return redirectToAccountErrorPage(authSession, error, parameters); + } else { + return null; + } + } + + private ParsedCodeContext parseSessionCode(String code, String clientId, String tabId) { + logger.info("parsing with code = " + code + ", clientId = " + clientId + ", tabId = " + tabId); + + if (code == null || clientId == null || tabId == null) { + System.out.printf("Invalid request. Authorization code, clientId or tabId was null. Code=%s, " + + "clientId=%s, tabID=%s", code + , clientId, tabId); + Response staleCodeError = redirectToErrorPage(Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + return ParsedCodeContext.response(staleCodeError); + } + + logger.info("check with session = " + Util.inspect("session", session)); + SessionCodeChecks checks = new SessionCodeChecks(realmModel, session.getContext().getUri(), request, clientConnection, session, event, null, code, null, clientId, tabId, LoginActionsService.AUTHENTICATE_PATH); + checks.initialVerify(); + if (!checks.verifyActiveAndValidAction(AuthenticationSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) { + + AuthenticationSessionModel authSession = checks.getAuthenticationSession(); + if (authSession != null) { + // Check if error happened during login or during linking from account management + Response accountManagementFailedLinking = checkAccountManagementFailedLinking(authSession, Messages.STALE_CODE_ACCOUNT); + if (accountManagementFailedLinking != null) { + return ParsedCodeContext.response(accountManagementFailedLinking); + } else { + Response errorResponse = checks.getResponse(); + + // Remove "code" from browser history + errorResponse = BrowserHistoryHelper.getInstance().saveResponseAndRedirect(session, authSession, errorResponse, true, request); + return ParsedCodeContext.response(errorResponse); + } + } else { + return ParsedCodeContext.response(checks.getResponse()); + } + } else { + logger.debugf("Authorization code is valid."); + + return ParsedCodeContext.clientSessionCode(checks.getClientCode()); + } + } + + private ParsedCodeContext parseEncodedSessionCode(String encodedCode) { + IdentityBrokerState state = IdentityBrokerState.encoded(encodedCode); + String code = state.getDecodedState(); + String clientId = state.getClientId(); + String tabId = state.getTabId(); + return parseSessionCode(code, clientId, tabId); + } + + private boolean shouldPerformAccountLinking(AuthenticationSessionModel authSession, UserSessionModel userSession, String providerId) { + String noteFromSession = authSession.getAuthNote(LINKING_IDENTITY_PROVIDER); + if (noteFromSession == null) { + return false; + } + + boolean linkingValid; + if (userSession == null) { + linkingValid = false; + } else { + String expectedNote = userSession.getId() + authSession.getClient().getClientId() + providerId; + linkingValid = expectedNote.equals(noteFromSession); + } + + if (linkingValid) { + authSession.removeAuthNote(LINKING_IDENTITY_PROVIDER); + return true; + } else { + throw new ErrorPageException(session, Response.Status.BAD_REQUEST, Messages.BROKER_LINKING_SESSION_EXPIRED); + } + } + + + private Response redirectToErrorWhenLinkingFailed(AuthenticationSessionModel authSession, String message, Object... parameters) { + if (authSession.getClient() != null && authSession.getClient().getClientId().equals(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)) { + return redirectToAccountErrorPage(authSession, message, parameters); + } else { + return redirectToErrorPage(authSession, Response.Status.BAD_REQUEST, message, parameters); // Should rather redirect to app instead and display error here? + } + } + + + private Response performAccountLinking(AuthenticationSessionModel authSession, UserSessionModel userSession, BrokeredIdentityContext context, FederatedIdentityModel newModel, UserModel federatedUser) { + logger.debugf("Will try to link identity provider [%s] to user [%s]", context.getIdpConfig().getAlias(), userSession.getUser().getUsername()); + + this.event.event(EventType.FEDERATED_IDENTITY_LINK); + + + UserModel authenticatedUser = userSession.getUser(); + authSession.setAuthenticatedUser(authenticatedUser); + + if (federatedUser != null && !authenticatedUser.getId().equals(federatedUser.getId())) { + return redirectToErrorWhenLinkingFailed(authSession, Messages.IDENTITY_PROVIDER_ALREADY_LINKED, context.getIdpConfig().getAlias()); + } + + if (!authenticatedUser.hasRole(this.realmModel.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.MANAGE_ACCOUNT))) { + return redirectToErrorPage(authSession, Response.Status.FORBIDDEN, Messages.INSUFFICIENT_PERMISSION); + } + + if (!authenticatedUser.isEnabled()) { + return redirectToErrorWhenLinkingFailed(authSession, Messages.ACCOUNT_DISABLED); + } + + + if (federatedUser != null) { + if (context.getIdpConfig().isStoreToken()) { + FederatedIdentityModel oldModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel); + if (!ObjectUtil.isEqualOrBothNull(context.getToken(), oldModel.getToken())) { + this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, newModel); + logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias()); + } + } + } else { + this.session.users().addFederatedIdentity(this.realmModel, authenticatedUser, newModel); + } + context.getIdp().authenticationFinished(authSession, context); + + AuthenticationManager.setClientScopesInSession(authSession); + TokenManager.attachAuthenticationSession(session, userSession, authSession); + + logger.debugf("Linking account [%s] from identity provider [%s] to user [%s].", newModel, context.getIdpConfig().getAlias(), authenticatedUser); + + this.event.user(authenticatedUser) + .detail(Details.USERNAME, authenticatedUser.getUsername()) + .detail(Details.IDENTITY_PROVIDER, newModel.getIdentityProvider()) + .detail(Details.IDENTITY_PROVIDER_USERNAME, newModel.getUserName()) + .success(); + + // we do this to make sure that the parent IDP is logged out when this user session is complete. + // But for the case when userSession was previously authenticated with broker1 and now is linked to another broker2, we shouldn't override broker1 notes with the broker2 for sure. + // Maybe broker logout should be rather always skiped in case of broker-linking + if (userSession.getNote(Details.IDENTITY_PROVIDER) == null) { + userSession.setNote(Details.IDENTITY_PROVIDER, context.getIdpConfig().getAlias()); + userSession.setNote(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); + } + + return Response.status(302).location(UriBuilder.fromUri(authSession.getRedirectUri()).build()).build(); + } + + + public Response validateUser(AuthenticationSessionModel authSession, UserModel user, RealmModel realm) { + if (!user.isEnabled()) { + event.error(Errors.USER_DISABLED); + return ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.ACCOUNT_DISABLED); + } + if (realm.isBruteForceProtected()) { + if (session.getProvider(BruteForceProtector.class).isTemporarilyDisabled(session, realm, user)) { + event.error(Errors.USER_TEMPORARILY_DISABLED); + return ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.ACCOUNT_DISABLED); + } + } + return null; + } + + private void updateToken(BrokeredIdentityContext context, UserModel federatedUser, FederatedIdentityModel federatedIdentityModel) { + if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) { + federatedIdentityModel.setToken(context.getToken()); + + this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel); + + logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias()); + } + } + + private void updateFederatedIdentity(BrokeredIdentityContext context, UserModel federatedUser) { + FederatedIdentityModel federatedIdentityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel); + + // Skip DB write if tokens are null or equal + updateToken(context, federatedUser, federatedIdentityModel); + context.getIdp().updateBrokeredUser(session, realmModel, federatedUser, context); + Set mappers = realmModel.getIdentityProviderMappersByAlias(context.getIdpConfig().getAlias()); + if (mappers != null) { + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper) sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + target.updateBrokeredUser(session, realmModel, federatedUser, mapper, context); + } + } + + } + + private Response checkPassiveLoginError(AuthenticationSessionModel authSession, String message) { + LoginProtocol.Error error = OAuthErrorException.LOGIN_REQUIRED.equals(message) ? LoginProtocol.Error.PASSIVE_LOGIN_REQUIRED : + (OAuthErrorException.INTERACTION_REQUIRED.equals(message) ? LoginProtocol.Error.PASSIVE_INTERACTION_REQUIRED : null); + if (error != null) { + LoginProtocol protocol = session.getProvider(LoginProtocol.class, authSession.getProtocol()); + protocol.setRealm(realmModel) + .setHttpHeaders(headers) + .setUriInfo(session.getContext().getUri()) + .setEventBuilder(event); + return protocol.sendError(authSession, error); + } + return null; + } + + private Response finishBrokerAuthentication(BrokeredIdentityContext context, UserModel federatedUser, AuthenticationSessionModel authSession, String providerId) { + authSession.setAuthNote(AuthenticationProcessor.BROKER_SESSION_ID, context.getBrokerSessionId()); + authSession.setAuthNote(AuthenticationProcessor.BROKER_USER_ID, context.getBrokerUserId()); + + this.event.user(federatedUser); + + context.getIdp().authenticationFinished(authSession, context); + authSession.setUserSessionNote(Details.IDENTITY_PROVIDER, providerId); + authSession.setUserSessionNote(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); + + event.detail(Details.IDENTITY_PROVIDER, providerId) + .detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); + + logger.debugf("Performing local authentication for user [%s].", federatedUser); + + AuthenticationManager.setClientScopesInSession(authSession); + + String nextRequiredAction = AuthenticationManager.nextRequiredAction(session, authSession, clientConnection, request, session.getContext().getUri(), event); + if (nextRequiredAction != null) { + if ("true".equals(authSession.getAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN))) { + logger.errorf("Required action %s found. Auth requests using prompt=none are incompatible with required actions", nextRequiredAction); + return checkPassiveLoginError(authSession, OAuthErrorException.INTERACTION_REQUIRED); + } + return AuthenticationManager.redirectToRequiredActions(session, realmModel, authSession, session.getContext().getUri(), nextRequiredAction); + } else { + event.detail(Details.CODE_ID, authSession.getParentSession().getId()); // todo This should be set elsewhere. find out why tests fail. Don't know where this is supposed to be set + + logger.info("Login success!"); + logger.info(Util.inspect("session", session)); + logger.info(Util.inspect("request", request)); + return AuthenticationManager.finishedRequiredActions(session, authSession, null, clientConnection, request, session.getContext().getUri(), event); + } + } + + private Response afterFirstBrokerLogin(ClientSessionCode clientSessionCode) { + AuthenticationSessionModel authSession = clientSessionCode.getClientSession(); + try { + this.event.detail(Details.CODE_ID, authSession.getParentSession().getId()) + .removeDetail("auth_method"); + + SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromAuthenticationSession(authSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE); + if (serializedCtx == null) { + throw new IdentityBrokerException("Not found serialized context in clientSession"); + } + BrokeredIdentityContext context = serializedCtx.deserialize(session, authSession); + String providerId = context.getIdpConfig().getAlias(); + + event.detail(Details.IDENTITY_PROVIDER, providerId); + event.detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); + + // Ensure the first-broker-login flow was successfully finished + String authProvider = authSession.getAuthNote(AbstractIdpAuthenticator.FIRST_BROKER_LOGIN_SUCCESS); + if (authProvider == null || !authProvider.equals(providerId)) { + throw new IdentityBrokerException("Invalid request. Not found the flag that first-broker-login flow was finished"); + } + + // firstBrokerLogin workflow finished. Removing note now + authSession.removeAuthNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE); + + UserModel federatedUser = authSession.getAuthenticatedUser(); + if (federatedUser == null) { + throw new IdentityBrokerException("Couldn't found authenticated federatedUser in authentication session"); + } + + event.user(federatedUser); + event.detail(Details.USERNAME, federatedUser.getUsername()); + + if (context.getIdpConfig().isAddReadTokenRoleOnCreate()) { + ClientModel brokerClient = realmModel.getClientByClientId(Constants.BROKER_SERVICE_CLIENT_ID); + if (brokerClient == null) { + throw new IdentityBrokerException("Client 'broker' not available. Maybe realm has not migrated to support the broker token exchange service"); + } + RoleModel readTokenRole = brokerClient.getRole(Constants.READ_TOKEN_ROLE); + federatedUser.grantRole(readTokenRole); + } + + // Add federated identity link here + FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(), + context.getUsername(), context.getToken()); + session.users().addFederatedIdentity(realmModel, federatedUser, federatedIdentityModel); + + + String isRegisteredNewUser = authSession.getAuthNote(AbstractIdpAuthenticator.BROKER_REGISTERED_NEW_USER); + if (Boolean.parseBoolean(isRegisteredNewUser)) { + + logger.debugf("Registered new user '%s' after first login with identity provider '%s'. Identity provider username is '%s' . ", federatedUser.getUsername(), providerId, context.getUsername()); + + context.getIdp().importNewUser(session, realmModel, federatedUser, context); + Set mappers = realmModel.getIdentityProviderMappersByAlias(providerId); + if (mappers != null) { + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper) sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + target.importNewUser(session, realmModel, federatedUser, mapper, context); + } + } + + if (context.getIdpConfig().isTrustEmail() && !Validation.isBlank(federatedUser.getEmail()) && !Boolean.parseBoolean(authSession.getAuthNote(AbstractIdpAuthenticator.UPDATE_PROFILE_EMAIL_CHANGED))) { + logger.debugf("Email verified automatically after registration of user '%s' through Identity provider '%s' ", federatedUser.getUsername(), context.getIdpConfig().getAlias()); + federatedUser.setEmailVerified(true); + } + + event.event(EventType.REGISTER) + .detail(Details.REGISTER_METHOD, "broker") + .detail(Details.EMAIL, federatedUser.getEmail()) + .success(); + + } else { + logger.debugf("Linked existing keycloak user '%s' with identity provider '%s' . Identity provider username is '%s' .", federatedUser.getUsername(), providerId, context.getUsername()); + + event.event(EventType.FEDERATED_IDENTITY_LINK) + .success(); + + updateFederatedIdentity(context, federatedUser); + } + + return finishOrRedirectToPostBrokerLogin(authSession, context, true, clientSessionCode); + + } catch (Exception e) { + return redirectToErrorPage(authSession, Response.Status.INTERNAL_SERVER_ERROR, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR, e); + } + } + + private Response afterPostBrokerLoginFlowSuccess(AuthenticationSessionModel authSession, BrokeredIdentityContext context, boolean wasFirstBrokerLogin, ClientSessionCode clientSessionCode) { + String providerId = context.getIdpConfig().getAlias(); + UserModel federatedUser = authSession.getAuthenticatedUser(); + + if (wasFirstBrokerLogin) { + return finishBrokerAuthentication(context, federatedUser, authSession, providerId); + } else { + + boolean firstBrokerLoginInProgress = (authSession.getAuthNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE) != null); + if (firstBrokerLoginInProgress) { + logger.debugf("Reauthenticated with broker '%s' when linking user '%s' with other broker", context.getIdpConfig().getAlias(), federatedUser.getUsername()); + + UserModel linkingUser = AbstractIdpAuthenticator.getExistingUser(session, realmModel, authSession); + if (!linkingUser.getId().equals(federatedUser.getId())) { + return redirectToErrorPage(authSession, Response.Status.BAD_REQUEST, "identityProviderDifferentUserMessage", federatedUser.getUsername(), linkingUser.getUsername()); + } + + SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromAuthenticationSession(authSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE); + authSession.setAuthNote(AbstractIdpAuthenticator.FIRST_BROKER_LOGIN_SUCCESS, serializedCtx.getIdentityProviderId()); + + return afterFirstBrokerLogin(clientSessionCode); + } else { + return finishBrokerAuthentication(context, federatedUser, authSession, providerId); + } + } + } + + private Response finishOrRedirectToPostBrokerLogin(AuthenticationSessionModel authSession, BrokeredIdentityContext context, boolean wasFirstBrokerLogin, ClientSessionCode clientSessionCode) { + String postBrokerLoginFlowId = context.getIdpConfig().getPostBrokerLoginFlowId(); + if (postBrokerLoginFlowId == null) { + + logger.debugf("Skip redirect to postBrokerLogin flow. PostBrokerLogin flow not set for identityProvider '%s'.", context.getIdpConfig().getAlias()); + return afterPostBrokerLoginFlowSuccess(authSession, context, wasFirstBrokerLogin, clientSessionCode); + } else { + + logger.debugf("Redirect to postBrokerLogin flow after authentication with identityProvider '%s'.", context.getIdpConfig().getAlias()); + + authSession.getParentSession().setTimestamp(Time.currentTime()); + + SerializedBrokeredIdentityContext ctx = SerializedBrokeredIdentityContext.serialize(context); + ctx.saveToAuthenticationSession(authSession, PostBrokerLoginConstants.PBL_BROKERED_IDENTITY_CONTEXT); + + authSession.setAuthNote(PostBrokerLoginConstants.PBL_AFTER_FIRST_BROKER_LOGIN, String.valueOf(wasFirstBrokerLogin)); + + URI redirect = LoginActionsService.postBrokerLoginProcessor(session.getContext().getUri()) + .queryParam(Constants.CLIENT_ID, authSession.getClient().getClientId()) + .queryParam(Constants.TAB_ID, authSession.getTabId()) + .build(realmModel.getName()); + return Response.status(302).location(redirect).build(); + } + } + + + @Override + public Response authenticated(BrokeredIdentityContext context) { + IdentityProviderModel identityProviderConfig = context.getIdpConfig(); + + final ParsedCodeContext parsedCode = parseEncodedSessionCode(context.getCode()); + + logger.info(Util.inspect("parsedCode", parsedCode)); + + if (parsedCode.response != null) { + logger.info("response = " + parsedCode.response); + + return parsedCode.response; + } + ClientSessionCode clientCode = parsedCode.clientSessionCode; + + logger.info("client code = " + clientCode); + + String providerId = identityProviderConfig.getAlias(); + if (!identityProviderConfig.isStoreToken()) { + logger.debugf("Token will not be stored for identity provider [%s].", providerId); + context.setToken(null); + } + + AuthenticationSessionModel authenticationSession = clientCode.getClientSession(); + context.setAuthenticationSession(authenticationSession); + + session.getContext().setClient(authenticationSession.getClient()); + + context.getIdp().preprocessFederatedIdentity(session, realmModel, context); + Set mappers = realmModel.getIdentityProviderMappersByAlias(context.getIdpConfig().getAlias()); + if (mappers != null) { + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper) sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + target.preprocessFederatedIdentity(session, realmModel, mapper, context); + } + } + + FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(providerId, context.getId(), + context.getUsername(), context.getToken()); + + this.event.event(EventType.IDENTITY_PROVIDER_LOGIN) + .detail(Details.REDIRECT_URI, authenticationSession.getRedirectUri()) + .detail(Details.IDENTITY_PROVIDER, providerId) + .detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); + + UserModel federatedUser = this.session.users().getUserByFederatedIdentity(federatedIdentityModel, this.realmModel); + + // Check if federatedUser is already authenticated (this means linking social into existing federatedUser account) + UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authenticationSession); + if (shouldPerformAccountLinking(authenticationSession, userSession, providerId)) { + return performAccountLinking(authenticationSession, userSession, context, federatedIdentityModel, federatedUser); + } + + if (federatedUser == null) { + + logger.debugf("Federated user not found for provider '%s' and broker username '%s' . Redirecting to flow for firstBrokerLogin", providerId, context.getUsername()); + + String username = context.getModelUsername(); + if (username == null) { + if (this.realmModel.isRegistrationEmailAsUsername() && !Validation.isBlank(context.getEmail())) { + username = context.getEmail(); + } else if (context.getUsername() == null) { + username = context.getIdpConfig().getAlias() + "." + context.getId(); + } else { + username = context.getUsername(); + } + } + username = username.trim(); + context.setModelUsername(username); + + boolean forwardedPassiveLogin = "true".equals(authenticationSession.getAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN)); + // Redirect to firstBrokerLogin after successful login and ensure that previous authentication state removed + AuthenticationProcessor.resetFlow(authenticationSession, LoginActionsService.FIRST_BROKER_LOGIN_PATH); + + // Set the FORWARDED_PASSIVE_LOGIN note (if needed) after resetting the session so it is not lost. + if (forwardedPassiveLogin) { + authenticationSession.setAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN, "true"); + } + + SerializedBrokeredIdentityContext ctx = SerializedBrokeredIdentityContext.serialize(context); + ctx.saveToAuthenticationSession(authenticationSession, AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE); + + URI redirect = LoginActionsService.firstBrokerLoginProcessor(session.getContext().getUri()) + .queryParam(Constants.CLIENT_ID, authenticationSession.getClient().getClientId()) + .queryParam(Constants.TAB_ID, authenticationSession.getTabId()) + .build(realmModel.getName()); + return Response.status(302).location(redirect).build(); + + } else { + Response response = validateUser(authenticationSession, federatedUser, realmModel); + if (response != null) { + return response; + } + + updateFederatedIdentity(context, federatedUser); + authenticationSession.setAuthenticatedUser(federatedUser); + + return finishOrRedirectToPostBrokerLogin(authenticationSession, context, false, parsedCode.clientSessionCode); + } + } + + @Override + public Response cancelled(String s) { + return null; + } + + @Override + public Response error(String s, String s1) { + return null; + } + + + public static class ParsedCodeContext { + private ClientSessionCode clientSessionCode; + private Response response; + + public static ParsedCodeContext clientSessionCode(ClientSessionCode clientSessionCode) { + ParsedCodeContext ctx = new ParsedCodeContext(); + ctx.clientSessionCode = clientSessionCode; + return ctx; + } + + public static ParsedCodeContext response(Response response) { + ParsedCodeContext ctx = new ParsedCodeContext(); + ctx.response = response; + return ctx; + } + } +} diff --git a/src/main/java/org/keycloak/social/weixin/WeiXinIdentityProvider.java b/src/main/java/org/keycloak/social/weixin/WeiXinIdentityProvider.java index 27d8fe8..477b55a 100644 --- a/src/main/java/org/keycloak/social/weixin/WeiXinIdentityProvider.java +++ b/src/main/java/org/keycloak/social/weixin/WeiXinIdentityProvider.java @@ -29,6 +29,7 @@ import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; +import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.OAuth2Constants; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; @@ -199,6 +200,12 @@ protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) { if (customizedLoginUrlForPc != null && !customizedLoginUrlForPc.isEmpty()) { uriBuilder = UriBuilder.fromUri(customizedLoginUrlForPc); + uriBuilder.queryParam(OAUTH2_PARAMETER_SCOPE, WECHAT_DEFAULT_SCOPE) + .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded()) + .queryParam(OAUTH2_PARAMETER_RESPONSE_TYPE, "code") + .queryParam(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getConfig().get(WECHAT_APPID_KEY)) + .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri()); + return uriBuilder; } else { uriBuilder = UriBuilder.fromUri(getConfig().getAuthorizationUrl()); @@ -260,6 +267,9 @@ protected class Endpoint { @Context protected UriInfo uriInfo; + @Context + protected HttpRequest request; + public Endpoint(AuthenticationCallback callback, RealmModel realm, EventBuilder event) { this.callback = callback; this.realm = realm; @@ -291,9 +301,19 @@ public Response authResponse(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_P BrokeredIdentityContext federatedIdentity; if (openid != null) { + // TODO: use ticket here instead, and then use this ticket to get openid from sso.jiwai.win federatedIdentity = customAuth.auth(openid); - return LoginWithFederatedIdentity(state, federatedIdentity, customAuth.accessToken); + setFederatedIdentity(state, federatedIdentity, customAuth.accessToken); + + logger.info(Util.inspect("federatedIdentity", federatedIdentity)); + + WeiXinIdentityBrokerService weiXinIdentityBrokerService = + new WeiXinIdentityBrokerService(realm); + weiXinIdentityBrokerService.init(session, clientConnection, headers, event, request); + + return weiXinIdentityBrokerService.authenticated(federatedIdentity); +// return callback.authenticated(federatedIdentity); } if (authorizationCode != null) { @@ -301,12 +321,16 @@ public Response authResponse(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_P logger.info("response=" + response); federatedIdentity = getFederatedIdentity(response, wechatFlag); - return LoginWithFederatedIdentity(state, federatedIdentity, response); + setFederatedIdentity(state, federatedIdentity, response); + + logger.info(Util.inspect("federatedIdentity", federatedIdentity)); + + return callback.authenticated(federatedIdentity); } } catch (WebApplicationException e) { return e.getResponse(); } catch (Exception e) { - logger.error("Failed to make identity provider oauth callback", e); + logger.error("Failed to make identity provider (weixin) oauth callback", e); } event.event(EventType.LOGIN); event.error(Errors.IDENTITY_PROVIDER_LOGIN_FAILURE); @@ -314,7 +338,7 @@ public Response authResponse(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_P Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR); } - public Response LoginWithFederatedIdentity(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_STATE) String state, BrokeredIdentityContext federatedIdentity, String accessToken) { + public void setFederatedIdentity(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_STATE) String state, BrokeredIdentityContext federatedIdentity, String accessToken) { if (getConfig().isStoreToken()) { if (federatedIdentity.getToken() == null) federatedIdentity.setToken(accessToken); @@ -323,8 +347,6 @@ public Response LoginWithFederatedIdentity(@QueryParam(AbstractOAuth2IdentityPro federatedIdentity.setIdpConfig(getConfig()); federatedIdentity.setIdp(WeiXinIdentityProvider.this); federatedIdentity.setCode(state); - - return callback.authenticated(federatedIdentity); } public SimpleHttp generateTokenRequest(String authorizationCode) { diff --git a/src/main/java/org/keycloak/social/weixin/WeixinIdentityCustomAuth.java b/src/main/java/org/keycloak/social/weixin/WeixinIdentityCustomAuth.java index 3d17ad2..7c94a82 100644 --- a/src/main/java/org/keycloak/social/weixin/WeixinIdentityCustomAuth.java +++ b/src/main/java/org/keycloak/social/weixin/WeixinIdentityCustomAuth.java @@ -12,7 +12,7 @@ public class WeixinIdentityCustomAuth extends AbstractOAuth2IdentityProvider implements SocialIdentityProvider { - private WeiXinIdentityProvider weiXinIdentityProvider; + private final WeiXinIdentityProvider weiXinIdentityProvider; public String accessToken; public WeixinIdentityCustomAuth(KeycloakSession session, OAuth2IdentityProviderConfig config, WeiXinIdentityProvider weiXinIdentityProvider) { @@ -45,9 +45,11 @@ public BrokeredIdentityContext auth(String openid) throws IOException { var profile = SimpleHttp.doGet(String.format("https://api.weixin.qq.com/cgi-bin/user/info?access_token=%s&openid" + "=%s&lang=zh_CN", accessToken, openid), this.session).asJson(); - System.out.println("profile is " + profile); - return this.weiXinIdentityProvider.extractIdentityFromProfile(null, profile); + var context = this.weiXinIdentityProvider.extractIdentityFromProfile(null, profile); + context.getContextData().put(FEDERATED_ACCESS_TOKEN, accessToken); + + return context; } } diff --git a/src/main/java/org/keycloak/social/weixin/WeixinProviderConfig.java b/src/main/java/org/keycloak/social/weixin/WeixinProviderConfig.java index 1e401a0..213bc3a 100644 --- a/src/main/java/org/keycloak/social/weixin/WeixinProviderConfig.java +++ b/src/main/java/org/keycloak/social/weixin/WeixinProviderConfig.java @@ -20,4 +20,8 @@ public void setCustomizedLoginUrlForPc(String customizedLoginUrlForPc) { public String getCustomizedLoginUrlForPc() { return this.getConfig().get(CUSTOMIZED_LOGIN_URL_FOR_PC); } + + public void setClientId2(String clientId2) { + this.getConfig().put("clientId2", clientId2); + } } diff --git a/src/test/java/org.keycloak.social.weixin/WeixinIdentityProviderTest.java b/src/test/java/org.keycloak.social.weixin/WeixinIdentityProviderTest.java index 660f9f9..b23a0a7 100644 --- a/src/test/java/org.keycloak.social.weixin/WeixinIdentityProviderTest.java +++ b/src/test/java/org.keycloak.social.weixin/WeixinIdentityProviderTest.java @@ -18,6 +18,8 @@ import javax.ws.rs.core.Response; +import static org.keycloak.social.weixin.WeiXinIdentityProvider.WECHAT_APPID_KEY; + @RunWith(PowerMockRunner.class) @PrepareForTest({UUID.class, WeiXinIdentityProvider.class}) public class WeixinIdentityProviderTest { @@ -85,6 +87,7 @@ public void pcGoesToQRConnect() { public void pcGoesToCustomizedURLIfPresent() { var config = new WeixinProviderConfig(); config.setClientId("clientId"); + config.setClientId2(WECHAT_APPID_KEY); config.setCustomizedLoginUrlForPc("https://another.url/path"); Assert.assertEquals("set config get config", "https://another.url/path", config.getCustomizedLoginUrlForPc()); @@ -95,12 +98,14 @@ public void pcGoesToCustomizedURLIfPresent() { var authSession = new MockedAuthenticationSessionModel(); HttpRequest httpRequest = new MockedHttpRequest(); - AuthenticationRequest request = new AuthenticationRequest(null, null, authSession, httpRequest, null, state, "https" + + AuthenticationRequest request = new AuthenticationRequest(null, null, authSession, httpRequest, null, state, + "https" + "://redirect.to.customized/url"); var res = weiXinIdentityProvider.performLogin(request); Assert.assertEquals("303 redirect", Response.Status.SEE_OTHER.getStatusCode(), res.getStatus()); - Assert.assertEquals("pc goes to customized login url", "https://another.url/path", res.getLocation().toString()); + Assert.assertEquals("pc goes to customized login url", true, + res.getLocation().toString().startsWith("https://another.url/path")); } } \ No newline at end of file diff --git a/src/test/java/org/keycloak/social/weixin/UtilTest.java b/src/test/java/org/keycloak/social/weixin/UtilTest.java new file mode 100644 index 0000000..84f8f65 --- /dev/null +++ b/src/test/java/org/keycloak/social/weixin/UtilTest.java @@ -0,0 +1,31 @@ +package org.keycloak.social.weixin; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.broker.provider.BrokeredIdentityContext; + +public class UtilTest { + @Test + public void inspectBrokeredIdentityContext() { + BrokeredIdentityContext context = new BrokeredIdentityContext("1234"); + + String inspected = Util.inspect("context", context); + + Assert.assertEquals("context >>>\n" + + "\tid: 1234\n" + + "\tusername: null\n" + + "\tmodelUsername: null\n" + + "\temail: null\n" + + "\tfirstName: null\n" + + "\tlastName: null\n" + + "\tbrokerSessionId: null\n" + + "\tbrokerUserId: null\n" + + "\tcode: null\n" + + "\ttoken: null\n" + + "\tidpConfig: null\n" + + "\tidp: null\n" + + "\tcontextData: {}\n" + + "\tauthenticationSession: null\n" + + "context <<<\n", inspected); + } +} \ No newline at end of file