├── .vscode └── settings.json ├── .gitignore ├── assets ├── config.png ├── dev-container.jpg └── heroku-bill.png ├── src ├── main │ ├── resources │ │ └── META-INF │ │ │ ├── services │ │ │ ├── org.keycloak.broker.social.SocialIdentityProviderFactory │ │ │ ├── org.keycloak.storage.UserStorageProviderFactory │ │ │ ├── org.keycloak.services.resource.RealmResourceProviderFactory │ │ │ └── org.keycloak.broker.provider.IdentityProviderFactory │ │ │ └── jboss-deployment-structure.xml │ ├── java │ │ └── org │ │ │ └── keycloak │ │ │ ├── social │ │ │ └── weixin │ │ │ │ ├── WechatLoginType.java │ │ │ │ ├── egress │ │ │ │ └── wechat │ │ │ │ │ └── mp │ │ │ │ │ ├── models │ │ │ │ │ ├── AccessTokenResponse.java │ │ │ │ │ ├── TicketResponse.java │ │ │ │ │ ├── ActionInfo.java │ │ │ │ │ ├── Scene.java │ │ │ │ │ ├── AccessTokenRequestBody.java │ │ │ │ │ └── TicketRequest.java │ │ │ │ │ └── WechatMpApi.java │ │ │ │ ├── JsonResponse.java │ │ │ │ ├── UserAgentHelper.java │ │ │ │ ├── helpers │ │ │ │ ├── UserAgentHelper.java │ │ │ │ ├── JsonHelper.java │ │ │ │ ├── WechatMpHelper.java │ │ │ │ └── WMPHelper.java │ │ │ │ ├── cache │ │ │ │ ├── TicketStatusProviderFactory.java │ │ │ │ ├── TicketEntity.java │ │ │ │ └── TicketStatusProvider.java │ │ │ │ ├── ParsedCodeContext.java │ │ │ │ ├── resources │ │ │ │ ├── QrCodeResourceProviderFactory.java │ │ │ │ ├── WechatCallbackResourceProviderFactory.java │ │ │ │ ├── WechatCallbackResourceProvider.java │ │ │ │ └── QrCodeResourceProvider.java │ │ │ │ ├── WeixinIdentityProviderConfig.java │ │ │ │ ├── OAuth2WeiXinIdentityProviderFactory.java │ │ │ │ ├── UserModelSerializer.java │ │ │ │ ├── WMPUserSessionModelSerializer.java │ │ │ │ ├── WMPUserSessionModel.java │ │ │ │ ├── WeixinIdentityCustomAuth.java │ │ │ │ ├── WeiXinIdentityProviderFactory.java │ │ │ │ ├── AuthenticatedWMPSession.java │ │ │ │ ├── OAuth2WeiXinIdentityProvider.java │ │ │ │ ├── Endpoint.java │ │ │ │ └── WeiXinIdentityProvider.java │ │ │ └── services │ │ │ └── resteasy │ │ │ └── HttpRequestImpl.java │ └── assembly │ │ └── assembly.xml └── test │ └── java │ └── org │ └── keycloak │ └── social │ └── weixin │ ├── JsonResponseTest.java │ ├── helpers │ ├── WMPHelperTest.java │ └── JsonHelperTest.java │ ├── WechatMiniProgramSessionTest.java │ ├── mock │ ├── MockedHttpRequest.java │ ├── MockedHttpHeaders.java │ ├── MockedAuthenticationSessionModel.java │ └── MockedKeycloakSession.java │ └── WeiXinIdentityProviderTest.java ├── Dockerfile ├── Dockerfile.19 ├── Dockerfile.26 ├── docker-compose.yml ├── docker-compose.19.yml ├── .devcontainer └── devcontainer.json ├── .run └── keycloak-services-social-weixin [package].run.xml ├── README_en-US.md ├── dependency-reduced-pom.xml ├── README.md └── pom.xml /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "java.configuration.updateBuildConfiguration": "automatic" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.classpath 3 | /.project 4 | /.settings/ 5 | .idea 6 | pom.xml.versionsBackup 7 | -------------------------------------------------------------------------------- /assets/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyqq163/keycloak-services-social-weixin/HEAD/assets/config.png -------------------------------------------------------------------------------- /assets/dev-container.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyqq163/keycloak-services-social-weixin/HEAD/assets/dev-container.jpg -------------------------------------------------------------------------------- /assets/heroku-bill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyqq163/keycloak-services-social-weixin/HEAD/assets/heroku-bill.png -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory: -------------------------------------------------------------------------------- 1 | org.keycloak.social.weixin.WeiXinIdentityProviderFactory -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory: -------------------------------------------------------------------------------- 1 | org.keycloak.social.weixin.cache.TicketStatusProviderFactory 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/keycloak/keycloak:18.0.2 2 | 3 | COPY target/keycloak-services-social-weixin-0.1.1.jar /opt/keycloak/providers/ 4 | 5 | CMD ["start-dev", "--hostname-strict=false"] -------------------------------------------------------------------------------- /Dockerfile.19: -------------------------------------------------------------------------------- 1 | FROM quay.io/keycloak/keycloak:latest 2 | 3 | COPY target/keycloak-services-social-weixin-0.1.1.jar /opt/keycloak/providers/ 4 | 5 | CMD ["start-dev", "--hostname-strict=false"] -------------------------------------------------------------------------------- /Dockerfile.26: -------------------------------------------------------------------------------- 1 | FROM quay.io/keycloak/keycloak:26.0 2 | 3 | COPY target/keycloak-services-social-weixin-0.6.17.jar /opt/keycloak/providers/ 4 | 5 | CMD ["start-dev", "--hostname-strict=false"] -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WechatLoginType.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | public enum WechatLoginType { 4 | FROM_PC_QR_CODE_SCANNING, 5 | FROM_WECHAT_BROWSER, 6 | FROM_WECHAT_MINI_PROGRAM, 7 | } 8 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory: -------------------------------------------------------------------------------- 1 | org.keycloak.social.weixin.resources.QrCodeResourceProviderFactory 2 | org.keycloak.social.weixin.resources.WechatCallbackResourceProviderFactory 3 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/JsonResponseTest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | class JsonResponseTest { 6 | 7 | @Test 8 | void fromJson() { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/models/AccessTokenResponse.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp.models; 2 | 3 | public class AccessTokenResponse { 4 | public String access_token; 5 | public String expires_in; 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/models/TicketResponse.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp.models; 2 | 3 | public class TicketResponse { 4 | public String ticket; 5 | public Number expire_seconds; 6 | public String url; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/models/ActionInfo.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp.models; 2 | 3 | public class ActionInfo { 4 | public Scene scene; 5 | 6 | public ActionInfo(Scene scene) { 7 | this.scene = scene; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/models/Scene.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp.models; 2 | 3 | public class Scene { 4 | public String scene_str; 5 | 6 | public Scene(String scene_str) { 7 | this.scene_str = scene_str; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/JsonResponse.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | 4 | import jakarta.ws.rs.core.Response; 5 | 6 | public class JsonResponse { 7 | public static Response fromJson(String json) { 8 | return Response.status(200).entity(json).build(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | keycloak: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | environment: 9 | KEYCLOAK_ADMIN: admin 10 | KEYCLOAK_ADMIN_PASSWORD: admin 11 | ports: 12 | - "8080:8080" 13 | command: 14 | - start-dev -------------------------------------------------------------------------------- /docker-compose.19.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | keycloak: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.19 8 | environment: 9 | KEYCLOAK_ADMIN: admin 10 | KEYCLOAK_ADMIN_PASSWORD: admin 11 | ports: 12 | - "8080:8080" 13 | command: 14 | - start-dev -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/UserAgentHelper.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import static org.keycloak.social.weixin.WeiXinIdentityProvider.WECHATFLAG; 4 | 5 | public class UserAgentHelper { 6 | public static boolean isWechatBrowser(String ua) { 7 | return ua.contains(WECHATFLAG); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/helpers/UserAgentHelper.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.helpers; 2 | 3 | import static org.keycloak.social.weixin.WeiXinIdentityProvider.WECHATFLAG; 4 | 5 | public class UserAgentHelper { 6 | public static boolean isWechatBrowser(String ua) { 7 | return ua.indexOf(WECHATFLAG) > 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/helpers/WMPHelperTest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.helpers; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.keycloak.social.weixin.helpers.WMPHelper; 5 | 6 | import static org.junit.jupiter.api.Assertions.*; 7 | 8 | class WMPHelperTest { 9 | 10 | @Test 11 | void createStateForWMP() { 12 | assertEquals("wmp.tab.client", WMPHelper.createStateForWMP("client", "tab", null)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/models/AccessTokenRequestBody.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp.models; 2 | 3 | public class AccessTokenRequestBody { 4 | public String grant_type; 5 | public String appid; 6 | public String secret; 7 | 8 | public AccessTokenRequestBody(String grant_type, String appid, String secret) { 9 | this.grant_type = grant_type; 10 | this.appid = appid; 11 | this.secret = secret; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/models/TicketRequest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp.models; 2 | 3 | import lombok.AllArgsConstructor; 4 | 5 | @AllArgsConstructor 6 | public class TicketRequest { 7 | public Number expire_seconds; 8 | public String action_name; 9 | public ActionInfo action_info; 10 | 11 | // public TicketRequest(Number expire_seconds, String action_name, ActionInfo action_info) { 12 | // this.expire_seconds = expire_seconds; 13 | // this.action_name = action_name; 14 | // this.action_info = action_info; 15 | // } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/assembly/assembly.xml: -------------------------------------------------------------------------------- 1 | 4 | jar-with-dependencies 5 | 6 | jar 7 | 8 | false 9 | 10 | 11 | lib/resteasy-core-spi-6.2.10.Final.jar 12 | / 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/cache/TicketStatusProviderFactory.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.cache; 2 | 3 | import org.keycloak.component.ComponentModel; 4 | import org.keycloak.models.KeycloakSession; 5 | import org.keycloak.storage.UserStorageProviderFactory; 6 | 7 | import java.util.Properties; 8 | 9 | public class TicketStatusProviderFactory implements UserStorageProviderFactory { 10 | @Override 11 | public TicketStatusProvider create(KeycloakSession keycloakSession, ComponentModel componentModel) { 12 | return new TicketStatusProvider(keycloakSession, componentModel); 13 | } 14 | 15 | @Override 16 | public String getId() { 17 | return "TicketStatusProvider"; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/WechatMiniProgramSessionTest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.Set; 6 | import java.util.stream.Stream; 7 | 8 | import static org.junit.jupiter.api.Assertions.*; 9 | 10 | class WechatMiniProgramSessionTest { 11 | 12 | Stream getRequiredActionsStream(AuthenticatedWMPSession session) { 13 | Set value = session.getRequiredActions(); 14 | return value != null ? value.stream() : Stream.empty(); 15 | } 16 | 17 | @Test 18 | void getRequiredActionsEmpty() { 19 | var sut = new AuthenticatedWMPSession(null, null, null); 20 | 21 | var firstAction = getRequiredActionsStream(sut).findFirst(); 22 | assertFalse(firstAction.isPresent()); 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2016 Red Hat, Inc. and/or its affiliates 3 | # and other contributors as indicated by the @author tags. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | org.keycloak.social.weixin.OAuth2WeiXinIdentityProviderFactory -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/ParsedCodeContext.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import jakarta.ws.rs.core.Response; 4 | import org.keycloak.services.managers.ClientSessionCode; 5 | import org.keycloak.sessions.AuthenticationSessionModel; 6 | 7 | public class ParsedCodeContext { 8 | public ClientSessionCode clientSessionCode; 9 | public Response response; 10 | 11 | public static ParsedCodeContext clientSessionCode(ClientSessionCode clientSessionCode) { 12 | ParsedCodeContext ctx = new ParsedCodeContext(); 13 | ctx.clientSessionCode = clientSessionCode; 14 | return ctx; 15 | } 16 | 17 | public static ParsedCodeContext response(Response response) { 18 | ParsedCodeContext ctx = new ParsedCodeContext(); 19 | ctx.response = response; 20 | return ctx; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/jboss-deployment-structure.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/cache/TicketEntity.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.cache; 2 | 3 | import jakarta.persistence.Id; 4 | import jakarta.persistence.NamedQueries; 5 | import jakarta.persistence.NamedQuery; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | import jakarta.persistence.Entity; 9 | 10 | @NamedQueries({ 11 | @NamedQuery(name = "TicketEntity.findById", query = "select t from TicketEntity t where t.id = :id"), 12 | @NamedQuery(name = "TicketEntity.findByTicket", query = "select t from TicketEntity t where t.ticket = :ticket"), 13 | }) 14 | @Getter 15 | @Entity 16 | public class TicketEntity { 17 | @Setter 18 | @Id 19 | private String id; 20 | @Setter 21 | private String ticket; 22 | @Setter 23 | private String status; 24 | @Setter 25 | private Number expireSeconds; 26 | @Setter 27 | private Number ticketCreatedAt; 28 | @Setter 29 | private Number scannedAt; 30 | @Setter 31 | private String openid; 32 | } 33 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/java 3 | { 4 | "name": "Java", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/java:1-17-bookworm", 7 | 8 | "features": { 9 | "ghcr.io/devcontainers/features/java:1": { 10 | "version": "none", 11 | "installMaven": "true", 12 | "installGradle": "false" 13 | } 14 | } 15 | 16 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 17 | // "forwardPorts": [], 18 | 19 | // Use 'postCreateCommand' to run commands after the container is created. 20 | // "postCreateCommand": "java -version", 21 | 22 | // Configure tool-specific properties. 23 | // "customizations": {}, 24 | 25 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 26 | // "remoteUser": "root" 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/resources/QrCodeResourceProviderFactory.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.resources; 2 | 3 | import org.keycloak.Config; 4 | import org.keycloak.models.KeycloakSession; 5 | import org.keycloak.models.KeycloakSessionFactory; 6 | import org.keycloak.services.resource.RealmResourceProvider; 7 | import org.keycloak.services.resource.RealmResourceProviderFactory; 8 | 9 | public class QrCodeResourceProviderFactory implements RealmResourceProviderFactory { 10 | @Override 11 | public RealmResourceProvider create(KeycloakSession session) { 12 | return new QrCodeResourceProvider(session); 13 | } 14 | 15 | @Override 16 | public void init(Config.Scope config) { 17 | 18 | } 19 | 20 | @Override 21 | public void postInit(KeycloakSessionFactory factory) { 22 | 23 | } 24 | 25 | @Override 26 | public void close() { 27 | 28 | } 29 | 30 | @Override 31 | public String getId() { 32 | return "QrCodeResourceProviderFactory"; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/resources/WechatCallbackResourceProviderFactory.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.resources; 2 | 3 | import org.keycloak.Config; 4 | import org.keycloak.models.KeycloakSession; 5 | import org.keycloak.models.KeycloakSessionFactory; 6 | import org.keycloak.services.resource.RealmResourceProvider; 7 | import org.keycloak.services.resource.RealmResourceProviderFactory; 8 | 9 | public class WechatCallbackResourceProviderFactory implements RealmResourceProviderFactory { 10 | @Override 11 | public RealmResourceProvider create(KeycloakSession session) { 12 | return new WechatCallbackResourceProvider(session); 13 | } 14 | 15 | @Override 16 | public void init(Config.Scope config) { 17 | 18 | } 19 | 20 | @Override 21 | public void postInit(KeycloakSessionFactory factory) { 22 | 23 | } 24 | 25 | @Override 26 | public void close() { 27 | 28 | } 29 | 30 | @Override 31 | public String getId() { 32 | return "WechatCallbackResourceProviderFactory"; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WeixinIdentityProviderConfig.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; 4 | import org.keycloak.models.IdentityProviderModel; 5 | 6 | public class WeixinIdentityProviderConfig extends OIDCIdentityProviderConfig { 7 | public WeixinIdentityProviderConfig() { 8 | } 9 | 10 | public WeixinIdentityProviderConfig(IdentityProviderModel model) { 11 | super(model); 12 | } 13 | 14 | public void setCustomizedLoginUrlForPc(String customizedLoginUrlForPc) { 15 | this.getConfig().put(WeiXinIdentityProvider.CUSTOMIZED_LOGIN_URL_FOR_PC, customizedLoginUrlForPc); 16 | } 17 | 18 | public String getCustomizedLoginUrlForPc() { 19 | return this.getConfig().get(WeiXinIdentityProvider.CUSTOMIZED_LOGIN_URL_FOR_PC); 20 | } 21 | 22 | public void setClientId2(String clientId2) { 23 | this.getConfig().put("clientId2", clientId2); 24 | } 25 | 26 | public void setWmpClientId(String clientId) { 27 | this.getConfig().put("wmpClientId", clientId); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.run/keycloak-services-social-weixin [package].run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/helpers/JsonHelper.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.helpers; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import org.keycloak.models.UserModel; 6 | import org.keycloak.social.weixin.UserModelSerializer; 7 | import org.keycloak.social.weixin.WMPUserSessionModel; 8 | import org.keycloak.social.weixin.WMPUserSessionModelSerializer; 9 | 10 | import java.lang.reflect.Type; 11 | 12 | public class JsonHelper { 13 | private static final Gson gson = 14 | new GsonBuilder().registerTypeAdapter(UserModel.class, new UserModelSerializer()).registerTypeAdapter(WMPUserSessionModel.class, new WMPUserSessionModelSerializer()).enableComplexMapKeySerialization().serializeNulls().setPrettyPrinting().create(); 15 | 16 | public static String stringify(Object anything) { 17 | return gson.toJson(anything); 18 | } 19 | 20 | public static String stringify(Object anything, Type type) { 21 | return gson.toJson(anything, type); 22 | } 23 | 24 | public static Object parse(String s) { 25 | return gson.fromJson(s, Object.class); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/mock/MockedHttpRequest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.mock; 2 | 3 | import jakarta.ws.rs.core.HttpHeaders; 4 | import jakarta.ws.rs.core.MultivaluedMap; 5 | import jakarta.ws.rs.core.UriInfo; 6 | import org.keycloak.http.FormPartValue; 7 | 8 | import java.security.cert.X509Certificate; 9 | 10 | public class MockedHttpRequest implements org.keycloak.http.HttpRequest { 11 | @Override 12 | public String getHttpMethod() { 13 | return null; 14 | } 15 | 16 | @Override 17 | public MultivaluedMap getDecodedFormParameters() { 18 | return null; 19 | } 20 | 21 | @Override 22 | public MultivaluedMap getMultiPartFormParameters() { 23 | return null; 24 | } 25 | 26 | @Override 27 | public HttpHeaders getHttpHeaders() { 28 | MockedHttpHeaders headers = new MockedHttpHeaders(); 29 | 30 | return headers; 31 | } 32 | 33 | @Override 34 | public X509Certificate[] getClientCertificateChain() { 35 | return new X509Certificate[0]; 36 | } 37 | 38 | @Override 39 | public UriInfo getUri() { 40 | return null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/OAuth2WeiXinIdentityProviderFactory.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 4 | import org.keycloak.broker.provider.AbstractIdentityProviderFactory; 5 | import org.keycloak.broker.social.SocialIdentityProviderFactory; 6 | import org.keycloak.models.IdentityProviderModel; 7 | import org.keycloak.models.KeycloakSession; 8 | 9 | public class OAuth2WeiXinIdentityProviderFactory extends AbstractIdentityProviderFactory implements 10 | SocialIdentityProviderFactory { 11 | 12 | public static final String PROVIDER_ID = "weixin-oauth2"; 13 | 14 | @Override 15 | public String getId() { 16 | return PROVIDER_ID; 17 | } 18 | 19 | @Override 20 | public String getName() { 21 | return "WeiXin OAuth2"; 22 | } 23 | 24 | @Override 25 | public OAuth2WeiXinIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { 26 | return new OAuth2WeiXinIdentityProvider(session, new OAuth2IdentityProviderConfig(model)); 27 | } 28 | 29 | @Override 30 | public OAuth2IdentityProviderConfig createConfig() { 31 | return new OAuth2IdentityProviderConfig(); 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/mock/MockedHttpHeaders.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.mock; 2 | 3 | import jakarta.ws.rs.core.Cookie; 4 | import jakarta.ws.rs.core.HttpHeaders; 5 | import jakarta.ws.rs.core.MediaType; 6 | import jakarta.ws.rs.core.MultivaluedMap; 7 | 8 | import java.util.Date; 9 | import java.util.List; 10 | import java.util.Locale; 11 | import java.util.Map; 12 | 13 | public class MockedHttpHeaders implements HttpHeaders { 14 | @Override 15 | public List getRequestHeader(String s) { 16 | return null; 17 | } 18 | 19 | @Override 20 | public String getHeaderString(String s) { 21 | return s; 22 | } 23 | 24 | @Override 25 | public MultivaluedMap getRequestHeaders() { 26 | return null; 27 | } 28 | 29 | @Override 30 | public List getAcceptableMediaTypes() { 31 | return null; 32 | } 33 | 34 | @Override 35 | public List getAcceptableLanguages() { 36 | return null; 37 | } 38 | 39 | @Override 40 | public MediaType getMediaType() { 41 | return null; 42 | } 43 | 44 | @Override 45 | public Locale getLanguage() { 46 | return null; 47 | } 48 | 49 | @Override 50 | public Map getCookies() { 51 | return null; 52 | } 53 | 54 | @Override 55 | public Date getDate() { 56 | return null; 57 | } 58 | 59 | @Override 60 | public int getLength() { 61 | return 0; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/UserModelSerializer.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import com.google.gson.JsonElement; 4 | import com.google.gson.JsonObject; 5 | import com.google.gson.JsonSerializationContext; 6 | import com.google.gson.JsonSerializer; 7 | import org.keycloak.models.UserModel; 8 | 9 | import java.lang.reflect.Type; 10 | import java.util.Objects; 11 | 12 | public class UserModelSerializer implements JsonSerializer { 13 | @Override 14 | public JsonElement serialize(UserModel userModel, Type type, JsonSerializationContext jsonSerializationContext) { 15 | var jsonObject = new JsonObject(); 16 | 17 | jsonObject.addProperty("username", userModel.getUsername()); 18 | jsonObject.addProperty("id", userModel.getId()); 19 | jsonObject.addProperty("email", userModel.getEmail()); 20 | jsonObject.addProperty("enabled", userModel.isEnabled()); 21 | jsonObject.addProperty("firstName", userModel.getFirstName()); 22 | jsonObject.addProperty("lastName", userModel.getLastName()); 23 | jsonObject.addProperty("createdTimestamp", userModel.getCreatedTimestamp()); 24 | jsonObject.addProperty("federationLink", userModel.getFederationLink()); 25 | jsonObject.addProperty("serviceAccountClientLink", userModel.getServiceAccountClientLink()); 26 | jsonObject.addProperty("groupsCount", userModel.getGroupsCount()); 27 | jsonObject.addProperty("attributes", Objects.requireNonNullElse(userModel.getAttributes(), "").toString()); 28 | 29 | return jsonObject; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/helpers/WechatMpHelper.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.helpers; 2 | 3 | import lombok.SneakyThrows; 4 | 5 | import java.security.MessageDigest; 6 | import java.security.NoSuchAlgorithmException; 7 | import java.util.Arrays; 8 | 9 | public class WechatMpHelper { 10 | /** 11 | * 判断是否是微信公众号消息 12 | *

13 | * 验证 URL Echostr 算法: 14 | * 1. 将 Token (用户在微信后台配置的值), 15 | * 时间戳(微信请求 URL 时传过来的 timestamp 值), 16 | * nonce(微信请求 URL 时传过来的 nonce 值)按照字母顺序排列; 17 | * 2. 排列好后拼成一个字符串; 18 | * 3. 通过 sha1 算法转换此字符串后的结果如果正常就是 echostr 的值。 19 | * 20 | * @return 21 | */ 22 | @SneakyThrows 23 | public static boolean isWechatMpMessage(String signature, String timestamp, String nonce) { 24 | var sortedArr = Arrays.stream(new String[]{"uni-heart", timestamp, nonce}).sorted().toArray(); 25 | return verify(signature, sortedArr); 26 | } 27 | 28 | @SneakyThrows 29 | public static boolean isWechatMpMessage(String token, String signature, String timestamp, String nonce) { 30 | var sortedArr = Arrays.stream(new String[]{token, timestamp, nonce}).sorted().toArray(); 31 | return verify(signature, sortedArr); 32 | } 33 | 34 | private static boolean verify(String signature, Object[] sortedArr) throws NoSuchAlgorithmException { 35 | StringBuilder content = new StringBuilder(); 36 | for (var item : sortedArr) { 37 | content.append(item); 38 | } 39 | 40 | var hash = MessageDigest.getInstance("SHA-1"); 41 | hash.update(content.toString().getBytes()); 42 | var hashed = hash.digest(); 43 | var hexDigest = new StringBuilder(); 44 | for (byte hashByte : hashed) { 45 | hexDigest.append(String.format("%02x", hashByte)); 46 | } 47 | 48 | var upperCase = hexDigest.toString().toUpperCase(); 49 | 50 | return upperCase.equals(signature.toUpperCase()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WMPUserSessionModelSerializer.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import com.google.gson.JsonElement; 4 | import com.google.gson.JsonObject; 5 | import com.google.gson.JsonSerializationContext; 6 | import com.google.gson.JsonSerializer; 7 | import org.keycloak.models.UserModel; 8 | import org.keycloak.social.weixin.helpers.JsonHelper; 9 | 10 | import java.lang.reflect.Type; 11 | import java.util.Objects; 12 | 13 | public class WMPUserSessionModelSerializer implements JsonSerializer { 14 | @Override 15 | public JsonElement serialize(WMPUserSessionModel wmpUserSessionModel, Type type, JsonSerializationContext jsonSerializationContext) { 16 | var jsonObject = new JsonObject(); 17 | jsonObject.addProperty("id", wmpUserSessionModel.getId()); 18 | jsonObject.addProperty("realm", Objects.requireNonNullElse(wmpUserSessionModel.getRealm(), "").toString()); 19 | jsonObject.addProperty("brokerSessionId", wmpUserSessionModel.getBrokerSessionId()); 20 | jsonObject.addProperty("brokerUserId", wmpUserSessionModel.getBrokerUserId()); 21 | jsonObject.addProperty("lastSessionRefresh", wmpUserSessionModel.getLastSessionRefresh()); 22 | jsonObject.addProperty("authMethod", wmpUserSessionModel.getAuthMethod()); 23 | jsonObject.addProperty("ipAddress", wmpUserSessionModel.getIpAddress()); 24 | jsonObject.addProperty("user", JsonHelper.stringify(wmpUserSessionModel.getUser(), UserModel.class)); 25 | jsonObject.addProperty("loginUserName", wmpUserSessionModel.getLoginUsername()); 26 | jsonObject.addProperty("started", wmpUserSessionModel.getStarted()); 27 | jsonObject.addProperty("notes", JsonHelper.stringify(wmpUserSessionModel.getNotes())); 28 | jsonObject.addProperty("authenticatedClientSessions", 29 | JsonHelper.stringify(wmpUserSessionModel.getAuthenticatedClientSessions())); 30 | jsonObject.addProperty("state", JsonHelper.stringify(wmpUserSessionModel.getState())); 31 | 32 | return jsonObject; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README_en-US.md: -------------------------------------------------------------------------------- 1 | # keycloak-services-social-weixin 2 | 3 | [中文](README.md) 4 | 5 | > Wechat Login for Keycloak 6 | 7 | ![Java CI with Maven](https://github.com/Jeff-Tian/keycloak-services-social-weixin/workflows/Java%20CI%20with%20Maven/badge.svg) 8 | ![Maven Package](https://github.com/Jeff-Tian/keycloak-services-social-weixin/workflows/Maven%20Package/badge.svg) 9 | 10 | ## Live Example 11 | 12 | - [Login In to UniSSO](https://keycloak.jiwai.win/auth/realms/UniHeart/protocol/openid-connect/auth?response_type=code&redirect_uri=http%3A%2F%2Fsso.jiwai.win%2Fkeycloak%2Flogin&client_id=UniHeart-Client-Local-3000) 13 | 14 | ## How to use it 15 | 16 | To install the social weixin one has to: 17 | 18 | * Add the jar to the Keycloak server: 19 | * `cp target/keycloak-services-social-weixin-*.jar _KEYCLOAK_HOME_/providers/` 20 | 21 | * Add three templates to the Keycloak server: 22 | * `cp templates/realm-identity-provider-weixin.html _KEYCLOAK_HOME_/themes/base/admin/resources/partials` 23 | * `cp templates/realm-identity-provider-weixin-ext.html _KEYCLOAK_HOME_/themes/base/admin/resources/partials` 24 | 25 | ## How to build 26 | 27 | ```shell script 28 | mvn install 29 | ``` 30 | 31 | ## Develop 32 | 33 | ```shell script 34 | mvn clean test 35 | ``` 36 | 37 | ## Maven package 38 | 39 | - For Keycloak 7.0.0: https://github.com/Jeff-Tian/keycloak-services-social-weixin/packages/225091?version=0.0.6 40 | 41 | - For Keycloak 15.0.2: https://github.com/Jeff-Tian/keycloak-services-social-weixin/packages/225091?version=0.0.22 42 | 43 | ## Screenshots 44 | 45 | ![image](https://user-images.githubusercontent.com/3367820/82117152-fdfd0300-97a0-11ea-8e10-02c9d9838a0a.png) 46 | 47 | ## Docker images 48 | 49 | [keycloak server docker](https://hub.docker.com/repository/docker/jefftian/keycloak-heroku): 50 | 51 | ```shell script 52 | docker pull jefftian/keycloak-heroku:latest 53 | ``` 54 | 55 | ## Deploy by one click 56 | 57 | Deploy to your own Heroku: 58 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?button-url=https%3A%2F%2Fgithub.com%2FJeff-Tian%2Fkeycloak-heroku&template=https%3A%2F%2Fgithub.com%2FJeff-Tian%2Fkeycloak-heroku) 59 | 60 | ## Release Notes 61 | 62 | * 20210805 63 | 64 | 1 Support Keycloak 15.0.2 65 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/egress/wechat/mp/WechatMpApi.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.egress.wechat.mp; 2 | 3 | import lombok.SneakyThrows; 4 | import org.jboss.logging.Logger; 5 | import org.keycloak.broker.provider.util.SimpleHttp; 6 | import org.keycloak.models.KeycloakSession; 7 | import org.keycloak.sessions.AuthenticationSessionModel; 8 | import org.keycloak.social.weixin.cache.TicketStatusProvider; 9 | import org.keycloak.social.weixin.egress.wechat.mp.models.AccessTokenResponse; 10 | import org.keycloak.social.weixin.egress.wechat.mp.models.TicketRequest; 11 | import org.keycloak.social.weixin.egress.wechat.mp.models.TicketResponse; 12 | 13 | 14 | public class WechatMpApi { 15 | private static final Logger logger = Logger.getLogger(WechatMpApi.class); 16 | private final String appSecret; 17 | private final String appId; 18 | protected final KeycloakSession session; 19 | protected final AuthenticationSessionModel authenticationSession; 20 | 21 | public WechatMpApi(String appId, String appSecret, KeycloakSession session, AuthenticationSessionModel authenticationSession) { 22 | this.appId = appId; 23 | this.appSecret = appSecret; 24 | this.session = session; 25 | this.authenticationSession = authenticationSession; 26 | } 27 | 28 | @SneakyThrows 29 | public AccessTokenResponse getAccessToken(String appId, String appSecret) { 30 | logger.info(String.format("getAccessToken by %s%n%s%n", appId, appSecret)); 31 | var res = 32 | SimpleHttp.doGet(String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential" + 33 | "&appid=%s&secret=%s", appId, appSecret), 34 | session).asJson(AccessTokenResponse.class); 35 | 36 | logger.info(String.format("res is %s%n", res)); 37 | 38 | return res; 39 | } 40 | 41 | @SneakyThrows 42 | public TicketResponse createTmpQrCode(TicketRequest ticketRequest) { 43 | logger.info(String.format("createTmpQrCode by %s%n", ticketRequest)); 44 | 45 | AccessTokenResponse tokenResponse = this.getAccessToken(appId, appSecret); 46 | if (tokenResponse == null || tokenResponse.access_token == null) { 47 | logger.error("Failed to get access token"); 48 | throw new RuntimeException("Failed to get WeChat access token"); 49 | } 50 | 51 | String url = String.format("https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=%s", 52 | tokenResponse.access_token); 53 | 54 | var res = SimpleHttp.doPost(url, session) 55 | .json(ticketRequest) 56 | .asJson(TicketResponse.class); 57 | 58 | logger.info(String.format("res is %s%n", res)); 59 | 60 | this.saveTicketStatus(res.ticket, res.expire_seconds); 61 | 62 | return res; 63 | } 64 | 65 | private void saveTicketStatus(String ticket, Number expireSeconds) { 66 | logger.info(String.format("saveTicketStatus by %s%n%s%n", ticket, expireSeconds)); 67 | 68 | var ticketStatusProvider = new TicketStatusProvider(session, null); 69 | 70 | ticketStatusProvider.saveTicketStatus(ticket, expireSeconds, "not_scanned"); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WMPUserSessionModel.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.keycloak.broker.provider.BrokeredIdentityContext; 4 | import org.keycloak.models.AuthenticatedClientSessionModel; 5 | import org.keycloak.models.RealmModel; 6 | import org.keycloak.models.UserModel; 7 | import org.keycloak.models.UserSessionModel; 8 | import org.keycloak.sessions.AuthenticationSessionModel; 9 | 10 | import java.util.Collection; 11 | import java.util.Map; 12 | 13 | public class WMPUserSessionModel implements UserSessionModel { 14 | private final BrokeredIdentityContext context; 15 | private final UserModel federatedUser; 16 | private final AuthenticationSessionModel authSession; 17 | 18 | public WMPUserSessionModel(BrokeredIdentityContext context, UserModel federatedUser, AuthenticationSessionModel authSession) { 19 | this.context = context; 20 | this.federatedUser = federatedUser; 21 | this.authSession = authSession; 22 | } 23 | 24 | @Override 25 | public String getId() { 26 | return context.getId(); 27 | } 28 | 29 | @Override 30 | public RealmModel getRealm() { 31 | return authSession.getRealm(); 32 | } 33 | 34 | @Override 35 | public String getBrokerSessionId() { 36 | return context.getBrokerSessionId(); 37 | } 38 | 39 | @Override 40 | public String getBrokerUserId() { 41 | return context.getBrokerUserId(); 42 | } 43 | 44 | @Override 45 | public UserModel getUser() { 46 | return federatedUser; 47 | } 48 | 49 | @Override 50 | public String getLoginUsername() { 51 | return context.getUsername(); 52 | } 53 | 54 | @Override 55 | public String getIpAddress() { 56 | return "0.0.0.0"; 57 | } 58 | 59 | @Override 60 | public String getAuthMethod() { 61 | return "WMP"; 62 | } 63 | 64 | @Override 65 | public boolean isRememberMe() { 66 | return false; 67 | } 68 | 69 | @Override 70 | public int getStarted() { 71 | return 0; 72 | } 73 | 74 | @Override 75 | public int getLastSessionRefresh() { 76 | return 0; 77 | } 78 | 79 | @Override 80 | public void setLastSessionRefresh(int i) { 81 | 82 | } 83 | 84 | @Override 85 | public boolean isOffline() { 86 | return false; 87 | } 88 | 89 | @Override 90 | public Map getAuthenticatedClientSessions() { 91 | return null; 92 | } 93 | 94 | @Override 95 | public void removeAuthenticatedClientSessions(Collection collection) { 96 | 97 | } 98 | 99 | @Override 100 | public String getNote(String s) { 101 | return s; 102 | } 103 | 104 | @Override 105 | public void setNote(String s, String s1) { 106 | 107 | } 108 | 109 | @Override 110 | public void removeNote(String s) { 111 | 112 | } 113 | 114 | @Override 115 | public Map getNotes() { 116 | return null; 117 | } 118 | 119 | @Override 120 | public State getState() { 121 | return null; 122 | } 123 | 124 | @Override 125 | public void setState(State state) { 126 | 127 | } 128 | 129 | @Override 130 | public void restartSession(RealmModel realmModel, UserModel userModel, String s, String s1, String s2, boolean b, String s3, String s4) { 131 | 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WeixinIdentityCustomAuth.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; 4 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 5 | import org.keycloak.broker.provider.BrokeredIdentityContext; 6 | import org.keycloak.broker.provider.util.SimpleHttp; 7 | import org.keycloak.broker.social.SocialIdentityProvider; 8 | import org.keycloak.models.KeycloakSession; 9 | 10 | import java.io.IOException; 11 | 12 | import static org.keycloak.social.weixin.UserAgentHelper.isWechatBrowser; 13 | import static org.keycloak.social.weixin.WeiXinIdentityProvider.WECHAT_MP_APP_ID; 14 | import static org.keycloak.social.weixin.WeiXinIdentityProvider.WECHAT_MP_APP_SECRET; 15 | 16 | public class WeixinIdentityCustomAuth extends AbstractOAuth2IdentityProvider 17 | implements SocialIdentityProvider { 18 | 19 | private final WeiXinIdentityProvider weiXinIdentityProvider; 20 | public String accessToken; 21 | 22 | public WeixinIdentityCustomAuth(KeycloakSession session, OAuth2IdentityProviderConfig config, WeiXinIdentityProvider weiXinIdentityProvider) { 23 | super(session, config); 24 | this.weiXinIdentityProvider = weiXinIdentityProvider; 25 | } 26 | 27 | // TODO: cache mechanism 28 | public String getAccessToken(WechatLoginType wechatLoginType) throws IOException { 29 | logger.info("getAccessToken with " + wechatLoginType); 30 | 31 | var clientId = this.getConfig().getClientId(); 32 | var clientSecret = this.getConfig().getClientSecret(); 33 | 34 | try { 35 | String ua = session.getContext().getRequestHeaders().getHeaderString("user-agent").toLowerCase(); 36 | logger.info("ua = " + ua); 37 | 38 | if (!isWechatBrowser(ua) || WechatLoginType.FROM_PC_QR_CODE_SCANNING.equals(wechatLoginType)) { 39 | logger.info("not wechat browser or from pc qr code scanning"); 40 | 41 | clientId = this.getConfig().getConfig().get(WECHAT_MP_APP_ID); 42 | clientSecret = this.getConfig().getConfig().get(WECHAT_MP_APP_SECRET); 43 | } 44 | } catch (Exception ex) { 45 | logger.error("获取 user-agent 失败"); 46 | logger.error(ex); 47 | } 48 | 49 | logger.info(String.format("getAccessToken by %s%n%s%n", clientId, clientSecret)); 50 | var res = 51 | SimpleHttp.doGet(String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential" + 52 | "&appid=%s&secret=%s", clientId, clientSecret), 53 | this.session).asString(); 54 | 55 | logger.info(String.format("res is %s%n", res)); 56 | var accessToken = this.extractTokenFromResponse(res, "access_token"); 57 | // var expiresIn = this.extractTokenFromResponse(res, "expires_in"); 58 | 59 | this.accessToken = accessToken; 60 | return accessToken; 61 | } 62 | 63 | @Override 64 | protected String getDefaultScopes() { 65 | return null; 66 | } 67 | 68 | public BrokeredIdentityContext auth(String openid, WechatLoginType wechatLoginType) throws IOException { 69 | var accessToken = getAccessToken(wechatLoginType); 70 | 71 | var profile = SimpleHttp.doGet(String.format("https://api.weixin.qq.com/cgi-bin/user/info?access_token=%s&openid" + 72 | "=%s&lang=zh_CN", accessToken, openid), this.session).asJson(); 73 | 74 | System.out.println("profile is " + profile); 75 | 76 | var context = this.weiXinIdentityProvider.extractIdentityFromProfile(null, profile); 77 | context.getContextData().put(FEDERATED_ACCESS_TOKEN, accessToken); 78 | 79 | return context; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/resources/WechatCallbackResourceProvider.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.resources; 2 | 3 | import jakarta.ws.rs.*; 4 | import jakarta.ws.rs.core.MediaType; 5 | import jakarta.ws.rs.core.Response; 6 | import lombok.SneakyThrows; 7 | import org.jboss.logging.Logger; 8 | import org.keycloak.models.KeycloakSession; 9 | import org.keycloak.services.resource.RealmResourceProvider; 10 | import org.keycloak.social.weixin.cache.TicketStatusProvider; 11 | import org.keycloak.social.weixin.helpers.WechatMpHelper; 12 | import org.w3c.dom.Document; 13 | import org.xml.sax.InputSource; 14 | 15 | import javax.xml.parsers.DocumentBuilder; 16 | import javax.xml.parsers.DocumentBuilderFactory; 17 | import java.io.StringReader; 18 | import java.util.ArrayList; 19 | import java.util.Map; 20 | import java.util.Objects; 21 | 22 | public class WechatCallbackResourceProvider implements RealmResourceProvider { 23 | protected static final Logger logger = Logger.getLogger(WechatCallbackResourceProvider.class); 24 | private final TicketStatusProvider ticketStatusProvider; 25 | private final KeycloakSession session; 26 | 27 | public WechatCallbackResourceProvider(KeycloakSession session) { 28 | this.session = session; 29 | this.ticketStatusProvider = new TicketStatusProvider(session, null); 30 | } 31 | 32 | @Override 33 | public Object getResource() { 34 | return this; 35 | } 36 | 37 | @Override 38 | public void close() { 39 | 40 | } 41 | 42 | @GET 43 | @Path("wechat-callback") 44 | @Produces(MediaType.TEXT_PLAIN) 45 | public Response wechatCallback(@QueryParam("signature") String signature, 46 | @QueryParam("timestamp") String timestamp, 47 | @QueryParam("nonce") String nonce, 48 | @QueryParam("echostr") String echostr) { 49 | logger.info("received wechat callback: %s, %s, %s, %s".formatted(signature, timestamp, nonce, echostr)); 50 | 51 | if (WechatMpHelper.isWechatMpMessage(signature, timestamp, nonce)) { 52 | logger.info("wechat-callback: %s verified.".formatted(echostr)); 53 | 54 | return Response.ok(echostr).build(); 55 | } 56 | 57 | return Response.notAcceptable(new ArrayList<>()).build(); 58 | } 59 | 60 | @SneakyThrows 61 | @POST 62 | @Path("wechat-callback") 63 | @Consumes(MediaType.APPLICATION_XML) 64 | @Produces(MediaType.APPLICATION_JSON) 65 | public Response wechatCallback(String xmlData) { 66 | logger.info("接收到微信服务器发来的事件: " + xmlData); 67 | 68 | DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 69 | DocumentBuilder builder = factory.newDocumentBuilder(); 70 | Document document = builder.parse(new InputSource(new StringReader(xmlData))); 71 | var root = document.getDocumentElement(); 72 | var xmlEvent = root.getElementsByTagName("Event").item(0).getTextContent(); 73 | 74 | if (!Objects.equals(xmlEvent, "SCAN")) { 75 | logger.info(String.format("ignoring not scanning event: {%s} != {%s}", xmlEvent, "SCAN")); 76 | return Response.ok(Map.of("status", "not_scanned")).build(); 77 | } 78 | 79 | var xmlTicket = root.getElementsByTagName("Ticket").item(0).getTextContent(); 80 | var xmlFromUserName = root.getElementsByTagName("FromUserName").item(0).getTextContent(); 81 | 82 | var ticketSaved = this.ticketStatusProvider.getTicketStatus(xmlTicket); 83 | if (ticketSaved == null) { 84 | logger.warn(String.format("ticket is not found, {%s}", xmlTicket)); 85 | return Response.ok(Map.of("status", "not_scanned")).build(); 86 | } 87 | 88 | ticketSaved.setStatus("scanned"); 89 | ticketSaved.setScannedAt(System.currentTimeMillis() / 1000L); 90 | ticketSaved.setOpenid(xmlFromUserName); 91 | 92 | this.ticketStatusProvider.saveTicketStatus(ticketSaved); 93 | 94 | return Response.ok(Map.of("status", "scanned")).build(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/mock/MockedAuthenticationSessionModel.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.mock; 2 | 3 | import org.keycloak.models.ClientModel; 4 | import org.keycloak.models.RealmModel; 5 | import org.keycloak.models.UserModel; 6 | import org.keycloak.sessions.AuthenticationSessionModel; 7 | import org.keycloak.sessions.RootAuthenticationSessionModel; 8 | 9 | import java.util.Map; 10 | import java.util.Set; 11 | 12 | public class MockedAuthenticationSessionModel implements AuthenticationSessionModel { 13 | @Override 14 | public String getTabId() { 15 | return null; 16 | } 17 | 18 | @Override 19 | public RootAuthenticationSessionModel getParentSession() { 20 | return null; 21 | } 22 | 23 | @Override 24 | public Map getExecutionStatus() { 25 | return null; 26 | } 27 | 28 | @Override 29 | public void setExecutionStatus(String s, ExecutionStatus executionStatus) { 30 | 31 | } 32 | 33 | @Override 34 | public void clearExecutionStatus() { 35 | 36 | } 37 | 38 | @Override 39 | public UserModel getAuthenticatedUser() { 40 | return null; 41 | } 42 | 43 | @Override 44 | public void setAuthenticatedUser(UserModel userModel) { 45 | 46 | } 47 | 48 | @Override 49 | public Set getRequiredActions() { 50 | return null; 51 | } 52 | 53 | @Override 54 | public void addRequiredAction(String s) { 55 | 56 | } 57 | 58 | @Override 59 | public void removeRequiredAction(String s) { 60 | 61 | } 62 | 63 | @Override 64 | public void addRequiredAction(UserModel.RequiredAction requiredAction) { 65 | 66 | } 67 | 68 | @Override 69 | public void removeRequiredAction(UserModel.RequiredAction requiredAction) { 70 | 71 | } 72 | 73 | @Override 74 | public void setUserSessionNote(String s, String s1) { 75 | 76 | } 77 | 78 | @Override 79 | public Map getUserSessionNotes() { 80 | return null; 81 | } 82 | 83 | @Override 84 | public void clearUserSessionNotes() { 85 | 86 | } 87 | 88 | @Override 89 | public String getAuthNote(String s) { 90 | return null; 91 | } 92 | 93 | @Override 94 | public void setAuthNote(String s, String s1) { 95 | 96 | } 97 | 98 | @Override 99 | public void removeAuthNote(String s) { 100 | 101 | } 102 | 103 | @Override 104 | public void clearAuthNotes() { 105 | 106 | } 107 | 108 | @Override 109 | public String getClientNote(String s) { 110 | return null; 111 | } 112 | 113 | @Override 114 | public void setClientNote(String s, String s1) { 115 | 116 | } 117 | 118 | @Override 119 | public void removeClientNote(String s) { 120 | 121 | } 122 | 123 | @Override 124 | public Map getClientNotes() { 125 | return null; 126 | } 127 | 128 | @Override 129 | public void clearClientNotes() { 130 | 131 | } 132 | 133 | @Override 134 | public Set getClientScopes() { 135 | return null; 136 | } 137 | 138 | @Override 139 | public void setClientScopes(Set set) { 140 | 141 | } 142 | 143 | @Override 144 | public String getRedirectUri() { 145 | return null; 146 | } 147 | 148 | @Override 149 | public void setRedirectUri(String s) { 150 | 151 | } 152 | 153 | @Override 154 | public RealmModel getRealm() { 155 | return null; 156 | } 157 | 158 | @Override 159 | public ClientModel getClient() { 160 | return null; 161 | } 162 | 163 | @Override 164 | public String getAction() { 165 | return null; 166 | } 167 | 168 | @Override 169 | public void setAction(String s) { 170 | 171 | } 172 | 173 | @Override 174 | public String getProtocol() { 175 | return null; 176 | } 177 | 178 | @Override 179 | public void setProtocol(String s) { 180 | 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WeiXinIdentityProviderFactory.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 4 | import org.keycloak.broker.provider.AbstractIdentityProviderFactory; 5 | import org.keycloak.broker.social.SocialIdentityProviderFactory; 6 | import org.keycloak.models.IdentityProviderModel; 7 | import org.keycloak.models.KeycloakSession; 8 | import org.keycloak.provider.ProviderConfigProperty; 9 | import org.keycloak.provider.ProviderConfigurationBuilder; 10 | 11 | import java.util.List; 12 | 13 | public class WeiXinIdentityProviderFactory extends 14 | AbstractIdentityProviderFactory implements 15 | SocialIdentityProviderFactory { 16 | 17 | public static final String PROVIDER_ID = "weixin"; 18 | 19 | @Override 20 | public String getName() { 21 | return "微信登录"; 22 | } 23 | 24 | @Override 25 | public WeiXinIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { 26 | return new WeiXinIdentityProvider(session, new WeixinIdentityProviderConfig(model)); 27 | } 28 | 29 | @Override 30 | public OAuth2IdentityProviderConfig createConfig() { 31 | return new OAuth2IdentityProviderConfig(); 32 | } 33 | 34 | @Override 35 | public String getId() { 36 | return PROVIDER_ID; 37 | } 38 | 39 | @Override 40 | public List getConfigProperties() { 41 | return ProviderConfigurationBuilder.create() 42 | .property().name(WeiXinIdentityProvider.WECHAT_MP_APP_ID) 43 | .label("PC 用的公众号 App Id") 44 | .helpText("当用户使用 PC 进行关注微信公众号即登录时,要使用的 app Id,即微信公众号(不是开放平台)的 appid。可以和上面的 Client ID 一样,也可以不一样") 45 | .type(ProviderConfigProperty.STRING_TYPE) 46 | .add() 47 | .property().name(WeiXinIdentityProvider.WECHAT_MP_APP_SECRET) 48 | .label("PC 用的公众号 App Secret") 49 | .helpText("当用户使用 PC 进行关注微信公众号即登录时,要使用的 app Secret,即微信公众号(不是开放平台)的 app secret。可以和上面的 Client Secret 一样,也可以不一样") 50 | .type(ProviderConfigProperty.STRING_TYPE) 51 | .add() 52 | .property().name(WeiXinIdentityProvider.WECHAT_MP_APP_TOKEN) 53 | .label("PC 用的公众号 服务器配置的Token") 54 | .helpText("当用户使用 PC 进行关注微信公众号即登录时,服务器配置选项的令牌,校验signature签名是否正确,以判断请求是否来自微信服务器。 (仅适用于明文模式)") 55 | .type(ProviderConfigProperty.STRING_TYPE) 56 | .add() 57 | 58 | .property().name(WeiXinIdentityProvider.CUSTOMIZED_LOGIN_URL_FOR_PC) 59 | .label("PC 登录 URL") 60 | .helpText("PC 登录 URL 的登录页面,可以配置为一个自定义的前端登录页面,用来展示公众号带参二维码") 61 | .type(ProviderConfigProperty.STRING_TYPE) 62 | .add() 63 | 64 | .property().name(WeiXinIdentityProvider.OPEN_CLIENT_ID) 65 | .label("开放平台 Client ID") 66 | .helpText("当用户使用微信开放平台登录时,要使用的 Client ID,即微信开放平台的 appid") 67 | .type(ProviderConfigProperty.STRING_TYPE) 68 | .add() 69 | .property().name(WeiXinIdentityProvider.OPEN_CLIENT_SECRET) 70 | .label("开放平台 Client Secret") 71 | .helpText("当用户使用微信开放平台登录时,要使用的 Client Secret,即微信开放平台的 app secret") 72 | .type(ProviderConfigProperty.STRING_TYPE) 73 | .add() 74 | .property().name(WeiXinIdentityProvider.OPEN_CLIENT_ENABLED) 75 | .label("是否启用开放平台登录") 76 | .helpText("是否启用开放平台登录,默认不启用,即使用关注公众号的方式登录。如果启用,则使用开放平台的方式登录") 77 | .type(ProviderConfigProperty.BOOLEAN_TYPE) 78 | .add() 79 | 80 | .property().name(WeiXinIdentityProvider.WMP_APP_ID) 81 | .label("小程序 appId") 82 | .helpText("小程序的 appid") 83 | .type(ProviderConfigProperty.STRING_TYPE) 84 | .add() 85 | .property().name(WeiXinIdentityProvider.WMP_APP_SECRET) 86 | .label("小程序 appSecret") 87 | .helpText("小程序的 app secret") 88 | .type(ProviderConfigProperty.STRING_TYPE) 89 | .add() 90 | 91 | .build(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/services/resteasy/HttpRequestImpl.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Red Hat, Inc. and/or its affiliates 3 | * and other contributors as indicated by the @author tags. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | package org.keycloak.services.resteasy; 18 | 19 | import java.io.IOException; 20 | import java.security.cert.X509Certificate; 21 | import java.util.Collection; 22 | import java.util.Map.Entry; 23 | 24 | import org.jboss.resteasy.core.ResteasyContext; 25 | import org.jboss.resteasy.reactive.server.multipart.FormValue; 26 | import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; 27 | import org.keycloak.http.FormPartValue; 28 | import org.keycloak.http.HttpRequest; 29 | import org.keycloak.services.FormPartValueImpl; 30 | 31 | import jakarta.ws.rs.core.HttpHeaders; 32 | import jakarta.ws.rs.core.MediaType; 33 | import static jakarta.ws.rs.core.MediaType.MULTIPART_FORM_DATA_TYPE; 34 | import jakarta.ws.rs.core.MultivaluedHashMap; 35 | import jakarta.ws.rs.core.MultivaluedMap; 36 | import jakarta.ws.rs.core.UriInfo; 37 | import jakarta.ws.rs.ext.MessageBodyReader; 38 | import jakarta.ws.rs.ext.Providers; 39 | 40 | public class HttpRequestImpl implements HttpRequest { 41 | 42 | private org.jboss.resteasy.spi.HttpRequest delegate; 43 | 44 | public HttpRequestImpl(org.jboss.resteasy.spi.HttpRequest delegate) { 45 | this.delegate = delegate; 46 | } 47 | 48 | @Override 49 | public String getHttpMethod() { 50 | if (delegate == null) { 51 | return null; 52 | } 53 | return delegate.getHttpMethod(); 54 | } 55 | 56 | @Override 57 | public MultivaluedMap getDecodedFormParameters() { 58 | if (delegate == null) { 59 | return null; 60 | } 61 | MediaType mediaType = getHttpHeaders().getMediaType(); 62 | if (mediaType == null || !mediaType.isCompatible(MediaType.valueOf("application/x-www-form-urlencoded"))) { 63 | return new MultivaluedHashMap<>(); 64 | } 65 | return delegate.getDecodedFormParameters(); 66 | } 67 | 68 | @Override 69 | public MultivaluedMap getMultiPartFormParameters() { 70 | try { 71 | MediaType mediaType = getHttpHeaders().getMediaType(); 72 | 73 | if (!MULTIPART_FORM_DATA_TYPE.isCompatible(mediaType) || !mediaType.getParameters().containsKey("boundary")) { 74 | return new MultivaluedHashMap<>(); 75 | } 76 | 77 | Providers providers = ResteasyContext.getContextData(Providers.class); 78 | MessageBodyReader multiPartProvider = providers.getMessageBodyReader( 79 | MultipartFormDataInput.class, null, null, MULTIPART_FORM_DATA_TYPE); 80 | MultipartFormDataInput inputs = multiPartProvider 81 | .readFrom(null, null, null, mediaType, getHttpHeaders().getRequestHeaders(), 82 | delegate.getInputStream()); 83 | MultivaluedHashMap parts = new MultivaluedHashMap<>(); 84 | 85 | for (Entry> entry : inputs.getValues().entrySet()) { 86 | for (FormValue value : entry.getValue()) { 87 | if (!value.isFileItem()) { 88 | parts.add(entry.getKey(), new FormPartValueImpl(value.getValue())); 89 | } else { 90 | parts.add(entry.getKey(), new FormPartValueImpl(value.getFileItem().getInputStream())); 91 | } 92 | } 93 | } 94 | 95 | return parts; 96 | } catch (IOException cause) { 97 | throw new RuntimeException("Failed to parse multi part request", cause); 98 | } 99 | } 100 | 101 | @Override 102 | public HttpHeaders getHttpHeaders() { 103 | if (delegate == null) { 104 | return null; 105 | } 106 | return delegate.getHttpHeaders(); 107 | } 108 | 109 | @Override 110 | public X509Certificate[] getClientCertificateChain() { 111 | if (delegate == null) { 112 | return null; 113 | } 114 | return (X509Certificate[]) delegate.getAttribute("jakarta.servlet.request.X509Certificate"); 115 | } 116 | 117 | @Override 118 | public UriInfo getUri() { 119 | if (delegate == null) { 120 | return null; 121 | } 122 | return delegate.getUri(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/AuthenticatedWMPSession.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; 4 | import org.keycloak.models.*; 5 | import org.keycloak.sessions.AuthenticationSessionModel; 6 | import org.keycloak.sessions.RootAuthenticationSessionModel; 7 | 8 | import java.util.Map; 9 | import java.util.Set; 10 | 11 | public class AuthenticatedWMPSession implements AuthenticationSessionModel { 12 | private final KeycloakSession session; 13 | private final RealmModel realmModel; 14 | private final UserModel authenticatedUser; 15 | 16 | public AuthenticatedWMPSession(KeycloakSession session, RealmModel realmModel, UserModel userModel) { 17 | this.session = session; 18 | this.realmModel = realmModel; 19 | this.authenticatedUser = userModel; 20 | } 21 | 22 | @Override 23 | public String getRedirectUri() { 24 | return "/stop"; 25 | } 26 | 27 | @Override 28 | public void setRedirectUri(String s) { 29 | 30 | } 31 | 32 | @Override 33 | public RealmModel getRealm() { 34 | return this.realmModel; 35 | } 36 | 37 | @Override 38 | public ClientModel getClient() { 39 | return this.realmModel.getClientByClientId(Constants.BROKER_SERVICE_CLIENT_ID); 40 | } 41 | 42 | @Override 43 | public String getAction() { 44 | System.out.println("getting action, I returned null"); 45 | return null; 46 | } 47 | 48 | @Override 49 | public void setAction(String s) { 50 | 51 | } 52 | 53 | @Override 54 | public String getProtocol() { 55 | return "protocol"; 56 | } 57 | 58 | @Override 59 | public void setProtocol(String s) { 60 | 61 | } 62 | 63 | @Override 64 | public String getTabId() { 65 | return "QRrSfbxHzaM"; 66 | } 67 | 68 | @Override 69 | public RootAuthenticationSessionModel getParentSession() { 70 | System.out.println("getParentSession = null, creating..."); 71 | var root = this.session.authenticationSessions().createRootAuthenticationSession(this.realmModel); 72 | 73 | return root; 74 | } 75 | 76 | @Override 77 | public Map getExecutionStatus() { 78 | return null; 79 | } 80 | 81 | @Override 82 | public void setExecutionStatus(String s, ExecutionStatus executionStatus) { 83 | 84 | } 85 | 86 | @Override 87 | public void clearExecutionStatus() { 88 | 89 | } 90 | 91 | @Override 92 | public UserModel getAuthenticatedUser() { 93 | return this.authenticatedUser; 94 | } 95 | 96 | @Override 97 | public void setAuthenticatedUser(UserModel userModel) { 98 | 99 | } 100 | 101 | @Override 102 | public Set getRequiredActions() { 103 | System.out.println("getRequiredActions is null to switch to respond directly"); 104 | return Set.of(); 105 | } 106 | 107 | @Override 108 | public void addRequiredAction(String s) { 109 | 110 | } 111 | 112 | @Override 113 | public void removeRequiredAction(String s) { 114 | 115 | } 116 | 117 | @Override 118 | public void addRequiredAction(UserModel.RequiredAction requiredAction) { 119 | 120 | } 121 | 122 | @Override 123 | public void removeRequiredAction(UserModel.RequiredAction requiredAction) { 124 | 125 | } 126 | 127 | @Override 128 | public void setUserSessionNote(String s, String s1) { 129 | 130 | } 131 | 132 | @Override 133 | public Map getUserSessionNotes() { 134 | return Map.of("user", "session"); 135 | } 136 | 137 | @Override 138 | public void clearUserSessionNotes() { 139 | 140 | } 141 | 142 | @Override 143 | public String getAuthNote(String s) { 144 | if (s.equals(AbstractIdpAuthenticator.EXISTING_USER_INFO)) { 145 | return "null"; 146 | } 147 | 148 | if (s.equals(WeiXinIdentityBrokerService.LINKING_IDENTITY_PROVIDER)) { 149 | System.out.println("getauthnote for " + WeiXinIdentityBrokerService.LINKING_IDENTITY_PROVIDER + "; returned Boolean null"); 150 | return null; 151 | } 152 | 153 | if(s.equals(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE)){ 154 | System.out.println("AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE = null"); 155 | return null; 156 | } 157 | 158 | System.out.println("Getting auth note with s = " + s + ", and I returned false for it"); 159 | return "false"; 160 | } 161 | 162 | @Override 163 | public void setAuthNote(String s, String s1) { 164 | 165 | } 166 | 167 | @Override 168 | public void removeAuthNote(String s) { 169 | 170 | } 171 | 172 | @Override 173 | public void clearAuthNotes() { 174 | 175 | } 176 | 177 | @Override 178 | public String getClientNote(String s) { 179 | if (s.equals(Constants.KC_ACTION)) { 180 | System.out.println("Getting client note with s = " + s + ", and I returned null"); 181 | return null; 182 | } 183 | 184 | return "note"; 185 | } 186 | 187 | @Override 188 | public void setClientNote(String s, String s1) { 189 | 190 | } 191 | 192 | @Override 193 | public void removeClientNote(String s) { 194 | 195 | } 196 | 197 | @Override 198 | public Map getClientNotes() { 199 | return Map.of("note1", "note2"); 200 | } 201 | 202 | @Override 203 | public void clearClientNotes() { 204 | 205 | } 206 | 207 | @Override 208 | public Set getClientScopes() { 209 | return Set.of("client_credential"); 210 | } 211 | 212 | @Override 213 | public void setClientScopes(Set set) { 214 | 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/WeiXinIdentityProviderTest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import jakarta.ws.rs.core.Response; 6 | import org.junit.*; 7 | import org.junit.jupiter.api.Assertions; 8 | import org.junit.runner.RunWith; 9 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 10 | import org.keycloak.broker.provider.AuthenticationRequest; 11 | import org.keycloak.broker.provider.BrokeredIdentityContext; 12 | import org.keycloak.broker.provider.IdentityProvider; 13 | import org.keycloak.broker.provider.util.IdentityBrokerState; 14 | import org.keycloak.social.weixin.mock.MockedAuthenticationSessionModel; 15 | import org.keycloak.social.weixin.mock.MockedHttpRequest; 16 | 17 | import java.util.Map; 18 | import java.util.UUID; 19 | 20 | import org.keycloak.social.weixin.mock.MockedKeycloakSession; 21 | import org.mockito.Mockito; 22 | import org.powermock.api.mockito.PowerMockito; 23 | import org.powermock.core.classloader.annotations.PrepareForTest; 24 | import org.powermock.modules.junit4.PowerMockRunner; 25 | 26 | import static org.hamcrest.CoreMatchers.containsString; 27 | 28 | @RunWith(PowerMockRunner.class) 29 | @PrepareForTest({UUID.class, WeiXinIdentityProvider.class}) 30 | public class WeiXinIdentityProviderTest { 31 | WeiXinIdentityProvider weiXinIdentityProvider; 32 | 33 | @Before 34 | public void before() { 35 | UUID uuid = PowerMockito.mock(UUID.class); 36 | Mockito.when(uuid.toString()).thenReturn("ccec3eea-fd08-4ca2-b83a-2921228f2480"); 37 | 38 | PowerMockito.mockStatic(UUID.class); 39 | PowerMockito.when(UUID.randomUUID()).thenReturn(uuid); 40 | 41 | OAuth2IdentityProviderConfig config = new OAuth2IdentityProviderConfig(); 42 | config.setClientId("clientId"); 43 | weiXinIdentityProvider = new WeiXinIdentityProvider(null, config); 44 | } 45 | 46 | @Test 47 | public void performLoginThrowsIfHttpRequestIsNull() { 48 | try { 49 | AuthenticationRequest request = new AuthenticationRequest(null, null, null, null, null, null, null); 50 | 51 | weiXinIdentityProvider.performLogin(request); 52 | } catch (RuntimeException ex) { 53 | Assert.assertThat(ex.toString(), containsString("org.keycloak.broker.provider.IdentityBrokerException: Could not create authentication request because java.lang.NullPointerException")); 54 | } 55 | } 56 | 57 | @Test 58 | public void pcGoesToQRConnect() { 59 | IdentityBrokerState state = IdentityBrokerState.decoded("state", "clientId", "clientId", "tabId", null); 60 | 61 | var authSession = new MockedAuthenticationSessionModel(); 62 | 63 | org.keycloak.http.HttpRequest httpRequest = new MockedHttpRequest(); 64 | AuthenticationRequest request = new AuthenticationRequest(new MockedKeycloakSession(httpRequest), null, authSession, httpRequest, null, state, "https" + 65 | "://redirect.to.customized/url"); 66 | 67 | var res = weiXinIdentityProvider.performLogin(request); 68 | Assert.assertEquals("303 redirect", Response.Status.SEE_OTHER.getStatusCode(), res.getStatus()); 69 | Assert.assertEquals("pc goes to customized login url", "https://open.weixin.qq" + 70 | ".com/connect/qrconnect?scope=snsapi_login&state=state.tabId" + 71 | ".Y2xpZW50SWQ&appid=clientId&redirect_uri=https%3A%2F%2Fredirect.to" + 72 | ".customized%2Furl&nonce=ccec3eea-fd08-4ca2-b83a-2921228f2480", res.getLocation().toString()); 73 | } 74 | 75 | @Test 76 | public void pcGoesToCustomizedURLIfPresent() { 77 | var config = new WeixinIdentityProviderConfig(); 78 | config.setClientId("clientId"); 79 | config.setClientId2(WeiXinIdentityProvider.WECHAT_MP_APP_ID); 80 | config.setCustomizedLoginUrlForPc("https://another.url/path"); 81 | 82 | Assert.assertEquals("set config get config", "https://another.url/path", config.getCustomizedLoginUrlForPc()); 83 | 84 | weiXinIdentityProvider = new WeiXinIdentityProvider(null, config); 85 | 86 | IdentityBrokerState state = IdentityBrokerState.decoded("state", "clientId", "clientId", "tabId", null); 87 | var authSession = new MockedAuthenticationSessionModel(); 88 | 89 | org.keycloak.http.HttpRequest httpRequest = new MockedHttpRequest(); 90 | AuthenticationRequest request = new AuthenticationRequest(new MockedKeycloakSession(httpRequest), null, authSession, httpRequest, null, state, 91 | "https" + 92 | "://redirect.to.customized/url"); 93 | 94 | var res = weiXinIdentityProvider.performLogin(request); 95 | 96 | Assert.assertEquals("303 redirect", Response.Status.SEE_OTHER.getStatusCode(), res.getStatus()); 97 | Assert.assertTrue("pc goes to customized login url", res.getLocation().toString().startsWith("https://another.url/path")); 98 | } 99 | 100 | @org.junit.jupiter.api.Test 101 | void getFederatedIdentityForWMP() throws JsonProcessingException { 102 | var mockSessionKey = "n1HE228Kq\\/i3HRlz\\/K71Aw=="; 103 | var mockUserInfo = Map.of("session_key", mockSessionKey); 104 | 105 | var mockAccessToken = "54_XzDD7MVKpVBX5m-VtsjAE9tyImVxUSKE2VgOzEBDemngNCAVwFfPr3RNusGjcBrZl2CPyQoONP4kqUI24Wl1KYZO-ZC2emmLR1bZfUPoH2FXd5iz780ZTOhb3lkDjK8zS0n31JdhXPwtPaqVDKHeAAAMTQ"; 106 | var expectedContextData = Map.of(IdentityProvider.FEDERATED_ACCESS_TOKEN, mockAccessToken, "UserInfo", mockUserInfo); 107 | 108 | final String sessionKeyResponse = "{\"session_key\":\"n1HE228Kq\\/i3HRlz\\/K71Aw==\",\"openid\":\"odrHN4p1UMWRdQfMK4xm9dtQXvf8\",\"unionid\":\"oLLUdsyyVLcjdxFXiOV2pZYuOdR0\"}"; 109 | var expectedJsonProfile = new ObjectMapper().readTree(sessionKeyResponse); 110 | 111 | var config = new WeixinIdentityProviderConfig(); 112 | config.setWmpClientId("123456"); 113 | 114 | var sut = new WeiXinIdentityProvider(null, config); 115 | 116 | var expectedUser = new BrokeredIdentityContext(sut.getJsonProperty(expectedJsonProfile, "unionid"), sut.getConfig()); 117 | expectedUser.setUsername(sut.getJsonProperty(expectedJsonProfile, "openid")); 118 | expectedUser.setEmail("null"); 119 | 120 | var res = sut.getFederatedIdentity(sessionKeyResponse, WechatLoginType.FROM_WECHAT_MINI_PROGRAM, "{\"access_token\":\"" + mockAccessToken + "\",\"expires_in\":7200}"); 121 | var contextData = res.getContextData(); 122 | Assertions.assertNotNull(contextData); 123 | Assertions.assertEquals(expectedContextData.get(IdentityProvider.FEDERATED_ACCESS_TOKEN), contextData.get(IdentityProvider.FEDERATED_ACCESS_TOKEN)); 124 | Assertions.assertEquals(expectedUser.toString(), res.toString()); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /dependency-reduced-pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | org.keycloak 5 | keycloak-services-social-weixin 6 | Keycloak Services Social WeiXin 7 | 0.6.17 8 | Keycloak 微信登录插件,支持 PC 端扫码登录、关注公众号即登录、手机微信等登录方式。 9 | https://zhuanlan.zhihu.com/p/652167012 10 | 11 | 12 | 13 | maven-surefire-plugin 14 | 3.5.2 15 | 16 | 17 | maven-clean-plugin 18 | 3.11.0 19 | 20 | 21 | maven-compiler-plugin 22 | 3.11.0 23 | 24 | 25 | org.springframework.boot 26 | spring-boot-maven-plugin 27 | 2.7.5 28 | 29 | 30 | 31 | org.projectlombok 32 | lombok 33 | 34 | 35 | 36 | 37 | 38 | maven-shade-plugin 39 | 3.6.0 40 | 41 | 42 | package 43 | 44 | shade 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | org.keycloak 54 | keycloak-quarkus-server 55 | 26.0.0 56 | provided 57 | 58 | 59 | io.quarkus.resteasy.reactive 60 | resteasy-reactive 61 | 3.19.0 62 | provided 63 | 64 | 65 | io.quarkus.resteasy.reactive 66 | resteasy-reactive-common 67 | 3.19.0 68 | provided 69 | 70 | 71 | org.keycloak 72 | keycloak-server-spi-private 73 | 26.0.0 74 | provided 75 | 76 | 77 | org.keycloak 78 | keycloak-services 79 | 26.0.0 80 | provided 81 | 82 | 83 | org.keycloak 84 | keycloak-server-spi 85 | 26.0.0 86 | provided 87 | 88 | 89 | org.keycloak 90 | keycloak-model-jpa 91 | 26.0.0 92 | provided 93 | 94 | 95 | org.junit.jupiter 96 | junit-jupiter-engine 97 | 5.9.0 98 | test 99 | 100 | 101 | junit-platform-engine 102 | org.junit.platform 103 | 104 | 105 | junit-jupiter-api 106 | org.junit.jupiter 107 | 108 | 109 | apiguardian-api 110 | org.apiguardian 111 | 112 | 113 | 114 | 115 | org.junit.platform 116 | junit-platform-runner 117 | 1.9.0 118 | test 119 | 120 | 121 | junit 122 | junit 123 | 124 | 125 | junit-platform-launcher 126 | org.junit.platform 127 | 128 | 129 | junit-platform-suite-api 130 | org.junit.platform 131 | 132 | 133 | junit-platform-suite-commons 134 | org.junit.platform 135 | 136 | 137 | apiguardian-api 138 | org.apiguardian 139 | 140 | 141 | 142 | 143 | org.powermock 144 | powermock-module-junit4 145 | 2.0.9 146 | test 147 | 148 | 149 | powermock-module-junit4-common 150 | org.powermock 151 | 152 | 153 | hamcrest-core 154 | org.hamcrest 155 | 156 | 157 | junit 158 | junit 159 | 160 | 161 | 162 | 163 | org.powermock 164 | powermock-api-mockito2 165 | 2.0.9 166 | test 167 | 168 | 169 | powermock-api-support 170 | org.powermock 171 | 172 | 173 | 174 | 175 | org.apache.httpcomponents 176 | httpcore 177 | 4.4.15 178 | test 179 | 180 | 181 | org.projectlombok 182 | lombok 183 | 1.18.26 184 | provided 185 | 186 | 187 | 188 | 17 189 | 17 190 | 17 191 | 26.0.0 192 | UTF-8 193 | 194 | 195 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/OAuth2WeiXinIdentityProvider.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import jakarta.ws.rs.core.UriBuilder; 5 | import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; 6 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 7 | import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; 8 | import org.keycloak.broker.provider.AuthenticationRequest; 9 | import org.keycloak.broker.provider.BrokeredIdentityContext; 10 | import org.keycloak.broker.provider.IdentityBrokerException; 11 | import org.keycloak.broker.social.SocialIdentityProvider; 12 | import org.keycloak.common.util.Time; 13 | import org.keycloak.events.EventBuilder; 14 | import org.keycloak.http.simple.SimpleHttp; 15 | import org.keycloak.http.simple.SimpleHttpRequest; 16 | import org.keycloak.http.simple.SimpleHttpResponse; 17 | import org.keycloak.models.KeycloakSession; 18 | import org.keycloak.models.RealmModel; 19 | import org.keycloak.util.JsonSerialization; 20 | 21 | import java.io.IOException; 22 | 23 | public class OAuth2WeiXinIdentityProvider extends AbstractOAuth2IdentityProvider 24 | implements SocialIdentityProvider { 25 | 26 | public static final String OAUTH2_PARAMETER_CLIENT_ID = "appid"; 27 | public static final String OAUTH2_PARAMETER_CLIENT_SECRET = "secret"; 28 | public static final String DEFAULT_SCOPE = "snsapi_login"; 29 | public static final String OPENID = "openid"; 30 | public static final String CACHE_OPENID = "weixin_openid"; 31 | 32 | @Override 33 | protected String getDefaultScopes() { 34 | return DEFAULT_SCOPE; 35 | } 36 | 37 | public OAuth2WeiXinIdentityProvider(KeycloakSession session, OAuth2IdentityProviderConfig config) { 38 | super(session, config); 39 | } 40 | 41 | @Override 42 | protected boolean supportsExternalExchange() { 43 | return true; 44 | } 45 | 46 | @Override 47 | public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) { 48 | return new Endpoint(callback, realm, event, this); 49 | } 50 | 51 | @Override 52 | protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) { 53 | UriBuilder uriBuilder = super.createAuthorizationUrl(request); 54 | uriBuilder.queryParam(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId()); 55 | return uriBuilder; 56 | } 57 | 58 | @Override 59 | public SimpleHttpRequest authenticateTokenRequest(SimpleHttpRequest tokenRequest) { 60 | SimpleHttpRequest simpleHttp = super.authenticateTokenRequest(tokenRequest); 61 | tokenRequest.param(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId()).param(OAUTH2_PARAMETER_CLIENT_SECRET, getConfig().getClientSecret()); 62 | return simpleHttp; 63 | } 64 | 65 | @Override 66 | public BrokeredIdentityContext getFederatedIdentity(String response) { 67 | String accessToken = extractTokenFromResponse(response, getAccessTokenResponseParameter()); 68 | if (accessToken == null) { 69 | throw new IdentityBrokerException("No access token available in OAuth server response: " + response); 70 | } 71 | String openid = extractTokenFromResponse(response, OPENID); 72 | if (openid == null) { 73 | throw new IdentityBrokerException("No openid available in OAuth server response: " + response); 74 | } 75 | session.setAttribute(CACHE_OPENID, extractTokenFromResponse(response, OPENID)); 76 | 77 | BrokeredIdentityContext context = doGetFederatedIdentity(accessToken); 78 | 79 | if (getConfig().isStoreToken() && response.startsWith("{")) { 80 | try { 81 | OAuthResponse tokenResponse = JsonSerialization.readValue(response, OAuthResponse.class); 82 | if (tokenResponse.getExpiresIn() != null && tokenResponse.getExpiresIn() > 0) { 83 | long accessTokenExpiration = Time.currentTime() + tokenResponse.getExpiresIn(); 84 | tokenResponse.setAccessTokenExpiration(accessTokenExpiration); 85 | response = JsonSerialization.writeValueAsString(tokenResponse); 86 | } 87 | context.setToken(response); 88 | } catch (IOException e) { 89 | logger.debugf("Can't store expiration date in JSON token", e); 90 | } 91 | } 92 | 93 | context.getContextData().put(FEDERATED_ACCESS_TOKEN, accessToken); 94 | return context; 95 | } 96 | 97 | @Override 98 | protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { 99 | String openId = (String)session.getAttribute(CACHE_OPENID); 100 | if (accessToken == null || openId == null) { 101 | throw new IdentityBrokerException("Missing access token or openid"); 102 | } 103 | 104 | try (SimpleHttpResponse response = SimpleHttp.create(session).doGet(getConfig().getUserInfoUrl()).param(getAccessTokenResponseParameter(), accessToken) 105 | .param(OPENID, openId).asResponse()) { 106 | 107 | JsonNode userInfo = response.asJson(); 108 | String unionid = getJsonProperty(userInfo, "unionid"); 109 | String userId = (unionid != null && !unionid.isEmpty()) ? unionid : openId; 110 | 111 | BrokeredIdentityContext identity = new BrokeredIdentityContext(userId, getConfig()); 112 | 113 | // 设置用户属性 114 | populateIdentityAttributes(identity, userInfo); 115 | 116 | identity.setIdp(this); 117 | return identity; 118 | 119 | } catch (Exception e) { 120 | throw new IdentityBrokerException("Error while fetching user profile", e); 121 | } 122 | } 123 | 124 | protected void populateIdentityAttributes(BrokeredIdentityContext identity, JsonNode userInfo) { 125 | AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias()); 126 | 127 | String givenName = getJsonProperty(userInfo, getConfig().getGivenNameClaim()); 128 | if (givenName != null) { 129 | identity.setFirstName(givenName); 130 | } 131 | 132 | String familyName = getJsonProperty(userInfo, getConfig().getFamilyNameClaim()); 133 | if (familyName != null) { 134 | identity.setLastName(familyName); 135 | } 136 | 137 | if (givenName == null && familyName == null) { 138 | String name = getJsonProperty(userInfo, getConfig().getFullNameClaim()); 139 | identity.setName(name); 140 | } 141 | 142 | String email = getJsonProperty(userInfo, getConfig().getEmailClaim()); 143 | identity.setEmail(email); 144 | 145 | identity.setBrokerUserId(getConfig().getAlias() + "." + identity.getId()); 146 | 147 | String preferredUsername = getJsonProperty(userInfo, getConfig().getUserNameClaim()); 148 | if (preferredUsername == null) { 149 | preferredUsername = email != null ? email : identity.getId(); 150 | } 151 | identity.setUsername(preferredUsername); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/helpers/WMPHelper.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.helpers; 2 | 3 | import java.util.Collections; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.stream.Stream; 7 | 8 | import org.keycloak.broker.provider.BrokeredIdentityContext; 9 | import org.keycloak.broker.provider.util.IdentityBrokerState; 10 | import org.keycloak.models.*; 11 | import org.keycloak.services.managers.ClientSessionCode; 12 | import org.keycloak.sessions.AuthenticationSessionModel; 13 | import org.keycloak.social.weixin.AuthenticatedWMPSession; 14 | import org.keycloak.social.weixin.WMPUserSessionModel; 15 | import org.keycloak.social.weixin.WeiXinIdentityBrokerService; 16 | 17 | public class WMPHelper { 18 | public static String createStateForWMP(String clientId, String tabId, String clientData) { 19 | return IdentityBrokerState.decoded("wmp", clientId, clientId, tabId, clientData).getEncoded(); 20 | } 21 | 22 | public static UserSessionModel getUserSessionModel(BrokeredIdentityContext context, UserModel federatedUser, AuthenticationSessionModel authSession) { 23 | return new WMPUserSessionModel(context, federatedUser, authSession); 24 | } 25 | 26 | public static ClientSessionCode getClientSessionCode(WeiXinIdentityBrokerService weiXinIdentityBrokerService, RealmModel realmModel, KeycloakSession session, BrokeredIdentityContext context) { 27 | final UserModel userModel = new UserModel() { 28 | @Override 29 | public String getId() { 30 | return context.getId(); 31 | } 32 | 33 | @Override 34 | public String getUsername() { 35 | return context.getUsername(); 36 | } 37 | 38 | @Override 39 | public void setUsername(String s) { 40 | 41 | } 42 | 43 | @Override 44 | public Long getCreatedTimestamp() { 45 | return null; 46 | } 47 | 48 | @Override 49 | public void setCreatedTimestamp(Long aLong) { 50 | 51 | } 52 | 53 | @Override 54 | public boolean isEnabled() { 55 | return true; 56 | } 57 | 58 | @Override 59 | public void setEnabled(boolean b) { 60 | 61 | } 62 | 63 | @Override 64 | public void setSingleAttribute(String s, String s1) { 65 | 66 | } 67 | 68 | @Override 69 | public void setAttribute(String s, List list) { 70 | 71 | } 72 | 73 | @Override 74 | public void removeAttribute(String s) { 75 | 76 | } 77 | 78 | @Override 79 | public String getFirstAttribute(String s) { 80 | return null; 81 | } 82 | 83 | @Override 84 | public Stream getAttributeStream(String s) { 85 | List attributeValues = this.getAttribute(s); 86 | 87 | return attributeValues.stream(); 88 | } 89 | 90 | private List getAttribute(String s) { 91 | return Collections.singletonList(context.getUserAttribute(s)); 92 | } 93 | 94 | @Override 95 | public Map> getAttributes() { 96 | return context.getAttributes(); 97 | } 98 | 99 | @Override 100 | public Stream getRequiredActionsStream() { 101 | return null; 102 | } 103 | 104 | @Override 105 | public void addRequiredAction(String s) { 106 | 107 | } 108 | 109 | @Override 110 | public void removeRequiredAction(String s) { 111 | 112 | } 113 | 114 | @Override 115 | public String getFirstName() { 116 | return context.getFirstName(); 117 | } 118 | 119 | @Override 120 | public void setFirstName(String s) { 121 | 122 | } 123 | 124 | @Override 125 | public String getLastName() { 126 | return context.getLastName(); 127 | } 128 | 129 | @Override 130 | public void setLastName(String s) { 131 | 132 | } 133 | 134 | @Override 135 | public String getEmail() { 136 | return context.getEmail(); 137 | } 138 | 139 | @Override 140 | public void setEmail(String s) { 141 | 142 | } 143 | 144 | @Override 145 | public boolean isEmailVerified() { 146 | return true; 147 | } 148 | 149 | @Override 150 | public void setEmailVerified(boolean b) { 151 | 152 | } 153 | 154 | @Override 155 | public Stream getGroupsStream() { 156 | return null; 157 | } 158 | 159 | @Override 160 | public void joinGroup(GroupModel groupModel) { 161 | 162 | } 163 | 164 | @Override 165 | public void leaveGroup(GroupModel groupModel) { 166 | 167 | } 168 | 169 | @Override 170 | public boolean isMemberOf(GroupModel groupModel) { 171 | return false; 172 | } 173 | 174 | @Override 175 | public String getFederationLink() { 176 | return null; 177 | } 178 | 179 | @Override 180 | public void setFederationLink(String s) { 181 | 182 | } 183 | 184 | @Override 185 | public String getServiceAccountClientLink() { 186 | return null; 187 | } 188 | 189 | @Override 190 | public void setServiceAccountClientLink(String s) { 191 | 192 | } 193 | 194 | @Override 195 | public SubjectCredentialManager credentialManager() { 196 | return null; 197 | } 198 | 199 | @Override 200 | public Stream getRealmRoleMappingsStream() { 201 | return null; 202 | } 203 | 204 | @Override 205 | public Stream getClientRoleMappingsStream(ClientModel clientModel) { 206 | return null; 207 | } 208 | 209 | @Override 210 | public boolean hasRole(RoleModel roleModel) { 211 | return false; 212 | } 213 | 214 | @Override 215 | public void grantRole(RoleModel roleModel) { 216 | 217 | } 218 | 219 | @Override 220 | public Stream getRoleMappingsStream() { 221 | return Stream.builder().build(); 222 | } 223 | 224 | @Override 225 | public void deleteRoleMapping(RoleModel roleModel) { 226 | 227 | } 228 | }; 229 | final AuthenticatedWMPSession wmpSession = new AuthenticatedWMPSession(session, weiXinIdentityBrokerService.realmModel, userModel); 230 | 231 | return new ClientSessionCode(session, realmModel, wmpSession); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/mock/MockedKeycloakSession.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.mock; 2 | 3 | import jakarta.ws.rs.core.HttpHeaders; 4 | import org.keycloak.common.ClientConnection; 5 | import org.keycloak.component.ComponentModel; 6 | import org.keycloak.http.HttpRequest; 7 | import org.keycloak.http.HttpResponse; 8 | import org.keycloak.models.*; 9 | import org.keycloak.provider.InvalidationHandler; 10 | import org.keycloak.provider.Provider; 11 | import org.keycloak.services.clientpolicy.ClientPolicyManager; 12 | import org.keycloak.sessions.AuthenticationSessionModel; 13 | import org.keycloak.sessions.AuthenticationSessionProvider; 14 | import org.keycloak.urls.UrlType; 15 | import org.keycloak.vault.VaultTranscriber; 16 | import org.keycloak.Token; 17 | 18 | import java.net.URI; 19 | import java.util.Locale; 20 | import java.util.Map; 21 | import java.util.Set; 22 | import java.util.function.Function; 23 | 24 | public class MockedKeycloakSession implements KeycloakSession { 25 | private final HttpRequest httpRequest; 26 | 27 | public MockedKeycloakSession(HttpRequest httpRequest) { 28 | this.httpRequest = httpRequest; 29 | } 30 | 31 | @Override 32 | public boolean isClosed() { 33 | return false; 34 | } 35 | 36 | @Override 37 | public IdentityProviderStorageProvider identityProviders() { 38 | return null; 39 | } 40 | 41 | @Override 42 | public KeycloakContext getContext() { 43 | return new KeycloakContext() { 44 | @Override 45 | public URI getAuthServerUrl() { 46 | return null; 47 | } 48 | 49 | @Override 50 | public String getContextPath() { 51 | return null; 52 | } 53 | 54 | @Override 55 | public KeycloakUriInfo getUri() { 56 | return null; 57 | } 58 | 59 | @Override 60 | public KeycloakUriInfo getUri(UrlType urlType) { 61 | return null; 62 | } 63 | 64 | @Override 65 | public HttpHeaders getRequestHeaders() { 66 | return httpRequest.getHttpHeaders(); 67 | } 68 | 69 | @Override 70 | public RealmModel getRealm() { 71 | return null; 72 | } 73 | 74 | @Override 75 | public void setRealm(RealmModel realmModel) { 76 | } 77 | 78 | @Override 79 | public ClientModel getClient() { 80 | return null; 81 | } 82 | 83 | @Override 84 | public void setClient(ClientModel clientModel) { 85 | } 86 | 87 | @Override 88 | public ClientConnection getConnection() { 89 | return null; 90 | } 91 | 92 | @Override 93 | public void setConnection(ClientConnection connection) { 94 | } 95 | 96 | @Override 97 | public Locale resolveLocale(UserModel userModel) { 98 | return null; 99 | } 100 | 101 | @Override 102 | public AuthenticationSessionModel getAuthenticationSession() { 103 | return null; 104 | } 105 | 106 | @Override 107 | public void setAuthenticationSession(AuthenticationSessionModel authenticationSessionModel) { 108 | } 109 | 110 | @Override 111 | public HttpRequest getHttpRequest() { 112 | return null; 113 | } 114 | 115 | @Override 116 | public void setHttpRequest(HttpRequest request) { 117 | } 118 | 119 | @Override 120 | public HttpResponse getHttpResponse() { 121 | return null; 122 | } 123 | 124 | @Override 125 | public void setHttpResponse(HttpResponse response) { 126 | } 127 | 128 | @Override 129 | public UserSessionModel getUserSession() { 130 | return null; 131 | } 132 | 133 | @Override 134 | public void setUserSession(UserSessionModel userSession) { 135 | } 136 | 137 | @Override 138 | public Token getBearerToken() { 139 | return null; 140 | } 141 | 142 | @Override 143 | public void setBearerToken(Token token) { 144 | } 145 | 146 | @Override 147 | public OrganizationModel getOrganization() { 148 | return null; 149 | } 150 | 151 | @Override 152 | public void setOrganization(OrganizationModel organization) { 153 | } 154 | }; 155 | } 156 | 157 | @Override 158 | public KeycloakTransactionManager getTransactionManager() { 159 | return null; 160 | } 161 | 162 | @Override 163 | public T getProvider(Class aClass) { 164 | return null; 165 | } 166 | 167 | @Override 168 | public T getProvider(Class aClass, String s) { 169 | return null; 170 | } 171 | 172 | @Override 173 | public T getComponentProvider(Class aClass, String s) { 174 | return null; 175 | } 176 | 177 | @Override 178 | public T getComponentProvider(Class aClass, String s, Function function) { 179 | return null; 180 | } 181 | 182 | @Override 183 | public T getProvider(Class aClass, ComponentModel componentModel) { 184 | return null; 185 | } 186 | 187 | @Override 188 | public Set listProviderIds(Class aClass) { 189 | return null; 190 | } 191 | 192 | @Override 193 | public Set getAllProviders(Class aClass) { 194 | return null; 195 | } 196 | 197 | @Override 198 | public Class getProviderClass(String s) { 199 | return null; 200 | } 201 | 202 | @Override 203 | public Object getAttribute(String s) { 204 | return null; 205 | } 206 | 207 | @Override 208 | public T getAttribute(String s, Class aClass) { 209 | return null; 210 | } 211 | 212 | @Override 213 | public Object removeAttribute(String s) { 214 | return null; 215 | } 216 | 217 | @Override 218 | public void setAttribute(String s, Object o) { 219 | 220 | } 221 | 222 | @Override 223 | public Map getAttributes() { 224 | return null; 225 | } 226 | 227 | @Override 228 | public void invalidate(InvalidationHandler.InvalidableObjectType invalidableObjectType, Object... objects) { 229 | 230 | } 231 | 232 | @Override 233 | public void enlistForClose(Provider provider) { 234 | 235 | } 236 | 237 | @Override 238 | public KeycloakSessionFactory getKeycloakSessionFactory() { 239 | return null; 240 | } 241 | 242 | @Override 243 | public RealmProvider realms() { 244 | return null; 245 | } 246 | 247 | @Override 248 | public ClientProvider clients() { 249 | return null; 250 | } 251 | 252 | @Override 253 | public ClientScopeProvider clientScopes() { 254 | return null; 255 | } 256 | 257 | @Override 258 | public GroupProvider groups() { 259 | return null; 260 | } 261 | 262 | @Override 263 | public RoleProvider roles() { 264 | return null; 265 | } 266 | 267 | @Override 268 | public UserSessionProvider sessions() { 269 | return null; 270 | } 271 | 272 | @Override 273 | public UserLoginFailureProvider loginFailures() { 274 | return null; 275 | } 276 | 277 | @Override 278 | public AuthenticationSessionProvider authenticationSessions() { 279 | return null; 280 | } 281 | 282 | @Override 283 | public SingleUseObjectProvider singleUseObjects() { 284 | return null; 285 | } 286 | 287 | @Override 288 | public void close() { 289 | 290 | } 291 | 292 | @Override 293 | public UserProvider users() { 294 | return null; 295 | } 296 | 297 | @Override 298 | public KeyManager keys() { 299 | return null; 300 | } 301 | 302 | @Override 303 | public ThemeManager theme() { 304 | return null; 305 | } 306 | 307 | @Override 308 | public TokenManager tokens() { 309 | return null; 310 | } 311 | 312 | @Override 313 | public VaultTranscriber vault() { 314 | return null; 315 | } 316 | 317 | @Override 318 | public ClientPolicyManager clientPolicy() { 319 | return null; 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keycloak-services-social-weixin 2 | 3 | [🇺🇸 English](README_en-US.md) | **[🇨🇳 简体中文](README.md)** 4 | 5 | > 感谢原作者Jeff-Tian的开源项目,本项目是在原项目的基础上进行了一些修改,主要是适配了quay.io/keycloak 26.0版本。 6 | > Keycloak 的微信登录插件,尝试在 Keycloak 里打通整个微信生态。相关文章:《[对接微信登录的三种方式 - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/659232648)》 7 | 8 | ![Java CI with Maven](https://github.com/Jeff-Tian/keycloak-services-social-weixin/workflows/Java%20CI%20with%20Maven/badge.svg) 9 | [![Maven Package](https://github.com/Jeff-Tian/keycloak-services-social-weixin/workflows/Maven%20Package/badge.svg)](https://github.com/Jeff-Tian/keycloak-services-social-weixin/packages) 10 | 11 | ## 在线体验 12 | 13 | - [点击我,右上角点击登录,然后选择使用微信登录](https://keycloak.jiwai.win/realms/Brickverse/account/#/ ) 14 | 15 | ## 如何使用 16 | 17 | 本项目是一个 Keycloak 的插件,所以你需要先有一个 Keycloak 实例,然后把本项目打包成 jar 包,放到 Keycloak 的 providers 目录下,然后重启 Keycloak 即可。即: 18 | 19 | * Add the jar to the Keycloak server: 20 | * `cp target/keycloak-services-social-weixin-*.jar _KEYCLOAK_HOME_/providers/` 21 | 22 | * 在生产环境下的keycloak,需要执行kc.sh build 注册provider 23 | 24 | ## 👨‍💻 本地开发 25 | 26 | 需要 JDK 17 或者以上。 27 | 28 | ```shell script 29 | mvn install 30 | ``` 31 | 32 | ::: tip 33 | 34 | 如果在本地碰到比如编译出错等问题,最简单的办法就是使用 GitHub CodeSpace,绕过环境问题。 35 | 36 | ![](./assets/dev-container.jpg) 37 | 38 | 以上就是我在 CodeSpace 里开发本项目的截图,其开发容器配置在[这里](./.devcontainer/devcontainer.json)。 39 | 40 | ::: 41 | 42 | ### 如何调试? 43 | 44 | 我一般都是通过添加日志,然后重启 Keycloak 服务,然后查看日志来排查问题。 45 | 46 | 原因是这并不是一个独立的程序,无法通过 IDE 直接运行或者调试(找不到 Main class)。它是嵌入在 Keycloak 里,通过 Keycloak 的 SPI 机制来运行的。我一般都是通过 Docker 方式启动 Keycloak 或者直接将该包加载到服务器上的 Keycloak 实例,然后观察本地或者服务器端的日志输出来排查问题的。 47 | 48 | 如果有人知道如何在 IDE 中本地调试 Keycloak 的 SPI 插件,欢迎提供帮助! 49 | 50 | ## 跑测试 51 | 52 | ```shell script 53 | mvn clean test 54 | ``` 55 | 56 | ## Maven 包 57 | 58 | [![](https://go.inversify.cn/api/dynamicimage?url=https://resume.jijiyy.me/zh-CN/jeff-tian/linked-in&width=332&height=242&version=v2)](https://www.linkedin.com/in/jeff~tian/) 59 | 60 | 我本是一名 JavaScript 程序员,使用 NodeJs 两年之后,就在 npm 上发布了 20 多个包。当开始折腾 Java 之后,也想在 Maven Central 中发布包,但折腾了很久之后,我放弃了——没想到这么复杂!发布到 Maven Central 的好处是可以方便其他项目在 pom.xml 中引用此包,所以还是有价值的,如果有谁知道怎么发布到 Maven Central,**请提供帮助**! 61 | 62 | > 你也可以直接 fork 本仓库,并将它发布到 Maven Central,善莫大焉。 63 | 64 | 目前我在 GitHub 上发布了,在 GitHub 发布后,如果要在 pom.xml 中引用,不仅需要在 pom.xml 中配置 GitHub Packages 的仓库地址,还需要一个访问令牌,有一些麻烦。 65 | 66 | 当然,你也可以直接下载 jar 包: 67 | 68 | - 支持 jboss/keycloak 16,你可以使用我打的包:https://github.com/Jeff-Tian/keycloak-services-social-weixin/packages/225091 69 | - 支持 quay.io/keycloak 18.0.2 的代码版本:https://github.com/Jeff-Tian/keycloak-services-social-weixin/tree/8069d7b32cb225742d7566d61e7ca0d0e0e389a5 70 | - 支持 quay.io/keycloak 21.1 的版本:https://github.com/Jeff-Tian/keycloak-services-social-weixin/tree/dev-keycloak-21 71 | - 支持 quay.io/keycloak 22 的版本: https://github.com/Jeff-Tian/keycloak-services-social-weixin/tree/dev-keycloak-22 72 | - 支持 quay.io/keycloak 26 的版本: 73 | 74 | 75 | ## 获取 jar 包 76 | 77 | ### 直接下载 78 | 79 | 你可以从 https://github.com/Jeff-Tian/keycloak-services-social-weixin/packages 获取已经打好的 jar 包,可以省去打包的步骤。 80 | 81 | ### 手动打包 82 | 83 | 如果需要自己手动打包,可以在本地命令行执行: 84 | 85 | ```shell 86 | mvn package 87 | ls target 88 | ``` 89 | 90 | ### 自动打包 91 | 92 | 本项目使用 GitHub Actions 自动打包,只需要在 master 分支上提交代码,即可自动打包。但是注意,需要修改 pom.xml 中的版本号,否则打包出来的 jar 包版本号和已经打好的 jar 包版本号冲突,从而不能上传到 GitHub Packages。 93 | 94 | ## 发版 95 | 96 | 本项目使用 GitHub Actions 自动发版,只需要在 master 分支上打一个 tag,然后在 GitHub 上发布一个 release 即可。不过,一般来说,也不需要手动打 tag。每次提交代码到 master 分支,GitHub Actions 都会检测是否有版本号的变化。如果版本号发生了变化,就会自动将该版本号做为新的 tag,并基于此来发布一个 release。详见: [这个 yml 文件](.github/workflows/release.yml) 。 97 | 98 | ## 版本更新 99 | 100 | 当需要更新本项目的版本时,需要修改 pom.xml 中的版本号。或者使用如下命令,比如将版本号改为 0.5.14: 101 | 102 | ```shell 103 | mvn versions:set -DnewVersion=0.5.14 104 | ``` 105 | 106 | ## 配置截图 107 | 108 | ### Keycloak 16 109 | 110 | ![image](https://user-images.githubusercontent.com/3367820/82117152-fdfd0300-97a0-11ea-8e10-02c9d9838a0a.png) 111 | 112 | ### Keycloak 22 113 | 114 | ![](./assets/config.png) 115 | 116 | Client ID 和 公众号 App Id;Client Secret 和 公众号 App Secret 都可以是一样的,即通过手机或者 PC 的微信登录时,都使用同一个公众号。但是以上截图用了两个不同的,其中公众号 App Id 使用了我的个人测试公众号,在关注人数在 100 以内时可以使用。而手机端,则必须使用经过认证的企业公众号(特别感谢知友 [hhhnnn](https://www.zhihu.com/people/hhhnnn-78) 帮我提供,没有该服务号我没法调通手机端)。 117 | 118 | ## Docker 镜像 119 | 120 | 我也打包了一个包含[微信 idp 的 keycloak server docker 镜像](https://hub.docker.com/repository/docker/jefftian/keycloak-heroku): 121 | 我补充了一个包含[微信 idp 的 keycloak server docker 镜像](https://hub.docker.com/r/monsterlin2024/keycloak-heroku): 122 | 123 | ```shell script 124 | docker pull jefftian/keycloak-heroku:latest 125 | 126 | docker pull monsterlin2024/keycloak-heroku:26.0-wx0.6 127 | ``` 128 | 129 | ## 一键部署 130 | 131 | ### 部署到 Heroku 132 | 133 | 点击这个按钮,可以部署一个包含微信登录的 Keycloak 到你自己的 Heroku: 134 | 135 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?button-url=https%3A%2F%2Fgithub.com%2FJeff-Tian%2Fkeycloak-heroku&template=https%3A%2F%2Fgithub.com%2FJeff-Tian%2Fkeycloak-heroku) 136 | 137 | ::: warning 注意 138 | Heroku 不再提供免费的 Dyno,部署到 Heroku 可能会产生费用。 139 | 140 | ![](./assets/heroku-bill.png) 141 | ::: 142 | 143 | ### 部署到 Okteto 144 | 145 | [【免费架构】Heroku 不免费了,何去何从之 Keycloak 的容器化部署之路 - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/611823061) 146 | 147 | ## 谁在使用 148 | 149 | | URL | 说明 | 源码 | 150 | |----------------------------|----------------------------------------------|----------------------------------------------| 151 | | https://keycloak.jiwai.win | 我部署在 heroku 上的 Keycloak 实例 | https://github.com/jeff-tian/keycloak-heroku | 152 | | https://www.da-yi-jia.com | 感谢[答疑家](https://www.da-yi-jia.com)对本项目的大力支持! | 153 | 154 | ## 💵 欢迎问我! 155 | 156 | 有任何相关问题,欢迎来知乎咨询: 157 | 158 | 向我咨询 159 | 160 | ## Release Notes 161 | 162 | * 2022090 163 | - 适配 quay.io/keycloak 18.0.2 164 | 165 | * 20180730 166 | - 增加自适应微信登录功能。 167 | - 账号关联默认使用微信unionid,如unionid不存在则使用openId 168 | - pc和wechat使用同一套账号则必须绑定同一个开放平台,否则会绑定不同账号 169 | - wechat信息非必填,默认使用pc方式登录 170 | 171 | * 20200514 172 | - 增加 customizedLoginUrlForPc 功能。 173 | 174 | * 20230820 175 | - 适配 quay.io/keycloak 21.1 的版本(由于 21 既不支持老的配置页,又没有新的方式增加自定义配置页,所以只能通过导入老的 Keycloak 版本中的 微信 identity provider 配置) 176 | 177 | * 20230823 178 | - 适配 quay.io/keycloak 22.0.1 的版本,可以正常显示所有的配置了![【重磅更新】关注微信公众号即登录插件升级支持 Keycloak 22! - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/652167012) ![](./assets/config.png) 179 | * 20230827 180 | - 新增对微信开放平台的支持。 [【继续更新】尝试在 Keycloak 里打通整个微信生态 - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/652566471) 181 | 182 | * 20240129([0.5.13](https://github.com/Jeff-Tian/keycloak-services-social-weixin/releases/tag/0.5.13)) 183 | - 优化关注公众号即登录方案的微信后台配置。 详见《[基于 Keycloak 的关注微信公众号即登录方案再次升级:有意思的成长 - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/680356153)》 184 | 185 | * 20250227([0.6.0] 186 | - 适配 quay.io/keycloak 26.0版本。由于keycloak 对类库org.keycloak.services.HttpRequestImpl进行修订,转而使用类库org.keycloak.quarkus.runtime.integration.resteasy.QuarkusHttpRequest。 187 | 188 | 189 | ## Star History 190 | 191 | 感谢大家的支持! 192 | 193 | [![Star History Chart](https://api.star-history.com/svg?repos=Jeff-Tian/keycloak-services-social-weixin,jyqq163/keycloak-services-social-weixin&type=Date)](https://star-history.com/#Jeff-Tian/keycloak-services-social-weixin&jyqq163/keycloak-services-social-weixin&Date) 194 | 195 | ## 致谢 196 | 197 | - 感谢 [jyqq163/keycloak-services-social-weixin](https://github.com/jyqq163/keycloak-services-social-weixin) 提供的基础代码,本仓库从该仓库 fork 而来。 198 | - 感谢 [hhhnnn](https://www.zhihu.com/people/hhhnnn-78) 提供的企业公众号,没有该服务号我没法调通手机端。 199 | - 感谢[各位](https://github.com/Jeff-Tian/keycloak-services-social-weixin/graphs/contributors)发的 pull request 和 issue,让本项目越来越好! 200 | 201 | ## 原理 202 | 203 | 其实任何一个 OAuth2/OIDC 的登录插件都是一样的,都是通过一个授权链接,然后通过 code 换取 access_token,再通过 access_token 换取用户信息。详见《[三步开发社交账号登录(以钉钉登录举例) - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/666423994) 》 204 | 205 | ### 以开放平台微信登录举例 206 | 207 | #### 先构建授权链接 208 | 209 | 链接如下: 210 | 211 | ``` 212 | https://open.weixin.qq.com/connect/qrconnect?scope=snsapi_login&state=d3Yvfou3pdgp-UNVZ-i7DTDEbv4rZTWx6Wh7lmxzyvk.98VO-haMdj4.c0L0bnybTEatKpqInU02nQ&response_type=code&appid=wxc09e145146844e43&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Fmaster%2Fbroker%2Fweixin%2Fendpoint 213 | ``` 214 | 215 | 用户使用微信扫描以上链接中展示的二维码后,会跳转到微信的授权页面,用户点击同意后,会跳转到我们的回调地址,并且带上 code 和 state 参数,如下: 216 | 217 | ``` 218 | https://keycloak.jiwai.win/realms/master/broker/weixin/endpoint?code=011er8000zwPzQ1Fvw200DTBCP1er80K&state=d3Yvfou3pdgp-UNVZ-i7DTDEbv4rZTWx6Wh7lmxzyvk.98VO-haMdj4.c0L0bnybTEatKpqInU02nQ 219 | ``` 220 | 221 | #### 通过 code 换取 access_token 222 | 223 | #### 通过 access_token 换取用户信息 224 | 225 | ## 🧧 [其他 Keycloak 社交登录插件](https://afdian.net/album/1270bba089c511eebb825254001e7c00) 226 | 227 | - [钉钉登录](https://github.com/Jeff-Tian/keycloak-services-social-dingding) 228 | - [企业微信](https://github.com/Jeff-Tian/keycloak-services-social-wechatwork) 229 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | 5 | org.keycloak 6 | keycloak-services-social-weixin 7 | jar 8 | 0.6.21 9 | 10 | Keycloak Services Social WeiXin 11 | Keycloak 微信登录插件,支持 PC 端扫码登录、关注公众号即登录、手机微信等登录方式。 12 | https://zhuanlan.zhihu.com/p/652167012 13 | 14 | 21 | 22 | 23 | UTF-8 24 | 17 25 | 26.1.3 26 | 17 27 | 17 28 | 29 | 30 | 31 | 38 | 39 | 40 | 41 | 42 | org.apache.maven.plugins 43 | maven-surefire-plugin 44 | 3.5.2 45 | 46 | 47 | --add-opens java.base/java.lang=ALL-UNNAMED 48 | --add-opens java.base/java.util=ALL-UNNAMED 49 | --add-opens java.base/java.util.concurrent=ALL-UNNAMED 50 | --add-opens java.base/java.net=ALL-UNNAMED 51 | --add-opens java.base/java.text=ALL-UNNAMED 52 | --add-opens java.base/java.lang.reflect=ALL-UNNAMED 53 | --add-opens java.base/java.io=ALL-UNNAMED 54 | --add-opens java.base/java.lang.ref=ALL-UNNAMED 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | org.apache.maven.plugins 65 | maven-compiler-plugin 66 | 3.11.0 67 | 68 | 69 | org.springframework.boot 70 | spring-boot-maven-plugin 71 | 2.7.5 72 | 73 | 74 | 75 | org.projectlombok 76 | lombok 77 | 78 | 79 | 80 | 81 | 82 | org.apache.maven.plugins 83 | maven-shade-plugin 84 | 3.6.0 85 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | org.jboss.resteasy 115 | resteasy-core 116 | 6.2.9.Final 117 | 118 | 119 | log4j 120 | log4j 121 | 122 | 123 | org.slf4j 124 | slf4j-api 125 | 126 | 127 | org.slf4j 128 | slf4j-simple 129 | 130 | 131 | 132 | 133 | org.keycloak 134 | keycloak-quarkus-server 135 | ${keycloak.version} 136 | provided 137 | 138 | 139 | 140 | org.jboss.resteasy 141 | resteasy-core-spi 142 | 6.2.9.Final 143 | 144 | 145 | org.jboss.resteasy 146 | resteasy-multipart-provider 147 | 6.2.10.Final 148 | 149 | 154 | 155 | io.quarkus.resteasy.reactive 156 | resteasy-reactive 157 | 3.19.0 158 | provided 159 | 160 | 161 | io.quarkus.resteasy.reactive 162 | resteasy-reactive-common 163 | 3.19.0 164 | provided 165 | 166 | 167 | 168 | 169 | org.keycloak 170 | keycloak-server-spi-private 171 | ${keycloak.version} 172 | provided 173 | 174 | 175 | 176 | org.keycloak 177 | keycloak-services 178 | ${keycloak.version} 179 | provided 180 | 181 | 182 | org.keycloak 183 | keycloak-server-spi 184 | ${keycloak.version} 185 | provided 186 | 187 | 188 | org.keycloak 189 | keycloak-model-jpa 190 | ${keycloak.version} 191 | provided 192 | 193 | 194 | 195 | org.junit.jupiter 196 | junit-jupiter-engine 197 | 5.9.0 198 | test 199 | 200 | 201 | org.junit.platform 202 | junit-platform-runner 203 | 1.9.0 204 | test 205 | 206 | 207 | 208 | org.mockito 209 | mockito-core 210 | 3.6.28 211 | 212 | 213 | org.powermock 214 | powermock-module-junit4 215 | 2.0.9 216 | test 217 | 218 | 219 | org.powermock 220 | powermock-api-mockito2 221 | 2.0.9 222 | test 223 | 224 | 225 | com.google.code.gson 226 | gson 227 | 2.9.0 228 | 229 | 230 | org.apache.httpcomponents 231 | httpcore 232 | 4.4.15 233 | test 234 | 235 | 236 | org.projectlombok 237 | lombok 238 | 1.18.30 239 | provided 240 | 241 | 242 | jakarta.persistence 243 | jakarta.persistence-api 244 | 3.1.0 245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/cache/TicketStatusProvider.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.cache; 2 | 3 | import jakarta.persistence.*; 4 | import jakarta.persistence.criteria.CriteriaBuilder; 5 | import jakarta.persistence.criteria.CriteriaDelete; 6 | import jakarta.persistence.criteria.CriteriaQuery; 7 | import jakarta.persistence.criteria.CriteriaUpdate; 8 | import jakarta.persistence.metamodel.Metamodel; 9 | import org.jboss.logging.Logger; 10 | import org.keycloak.component.ComponentModel; 11 | import org.keycloak.models.KeycloakSession; 12 | import org.keycloak.storage.UserStorageProvider; 13 | import org.keycloak.connections.jpa.JpaConnectionProvider; 14 | 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.Objects; 18 | import java.util.UUID; 19 | import java.util.concurrent.ConcurrentHashMap; 20 | 21 | public class TicketStatusProvider implements UserStorageProvider { 22 | private final KeycloakSession session; 23 | private final ComponentModel model; 24 | private static Map localCache = new ConcurrentHashMap<>(); 25 | 26 | private static final Logger logger = Logger.getLogger(TicketStatusProvider.class); 27 | 28 | protected EntityManager em; 29 | 30 | public TicketStatusProvider(KeycloakSession keycloakSession, ComponentModel componentModel) { 31 | this.session = keycloakSession; 32 | this.model = componentModel; 33 | var jpaProvider = session.getProvider(JpaConnectionProvider.class, "ticket-store"); 34 | 35 | if (jpaProvider != null) { 36 | this.em = jpaProvider.getEntityManager(); 37 | return; 38 | } 39 | 40 | this.em = new EntityManager() { 41 | @Override 42 | public void persist(Object o) { 43 | localCache.put(((TicketEntity) o).getTicket(), (TicketEntity) o); 44 | } 45 | 46 | @Override 47 | public T merge(T t) { 48 | return null; 49 | } 50 | 51 | @Override 52 | public void remove(Object o) { 53 | localCache.remove(((TicketEntity) o).getTicket()); 54 | } 55 | 56 | @Override 57 | public T find(Class aClass, Object o) { 58 | return (T) localCache.get((String) o); 59 | } 60 | 61 | @Override 62 | public T find(Class aClass, Object o, Map map) { 63 | return null; 64 | } 65 | 66 | @Override 67 | public T find(Class aClass, Object o, LockModeType lockModeType) { 68 | return null; 69 | } 70 | 71 | @Override 72 | public T find(Class aClass, Object o, LockModeType lockModeType, Map map) { 73 | return null; 74 | } 75 | 76 | @Override 77 | public T getReference(Class aClass, Object o) { 78 | return (T) o; 79 | } 80 | 81 | @Override 82 | public void flush() { 83 | } 84 | 85 | @Override 86 | public void setFlushMode(FlushModeType flushModeType) { 87 | 88 | } 89 | 90 | @Override 91 | public FlushModeType getFlushMode() { 92 | return null; 93 | } 94 | 95 | @Override 96 | public void lock(Object o, LockModeType lockModeType) { 97 | 98 | } 99 | 100 | @Override 101 | public void lock(Object o, LockModeType lockModeType, Map map) { 102 | 103 | } 104 | 105 | @Override 106 | public void refresh(Object o) { 107 | 108 | } 109 | 110 | @Override 111 | public void refresh(Object o, Map map) { 112 | 113 | } 114 | 115 | @Override 116 | public void refresh(Object o, LockModeType lockModeType) { 117 | 118 | } 119 | 120 | @Override 121 | public void refresh(Object o, LockModeType lockModeType, Map map) { 122 | 123 | } 124 | 125 | @Override 126 | public void clear() { 127 | localCache.clear(); 128 | } 129 | 130 | @Override 131 | public void detach(Object o) { 132 | 133 | } 134 | 135 | @Override 136 | public boolean contains(Object o) { 137 | return false; 138 | } 139 | 140 | @Override 141 | public LockModeType getLockMode(Object o) { 142 | return null; 143 | } 144 | 145 | @Override 146 | public void setProperty(String s, Object o) { 147 | 148 | } 149 | 150 | @Override 151 | public Map getProperties() { 152 | return null; 153 | } 154 | 155 | @Override 156 | public Query createQuery(String s) { 157 | return null; 158 | } 159 | 160 | @Override 161 | public TypedQuery createQuery(CriteriaQuery criteriaQuery) { 162 | return null; 163 | } 164 | 165 | @Override 166 | public Query createQuery(CriteriaUpdate criteriaUpdate) { 167 | return null; 168 | } 169 | 170 | @Override 171 | public Query createQuery(CriteriaDelete criteriaDelete) { 172 | return null; 173 | } 174 | 175 | @Override 176 | public TypedQuery createQuery(String s, Class aClass) { 177 | return null; 178 | } 179 | 180 | @Override 181 | public Query createNamedQuery(String s) { 182 | return null; 183 | } 184 | 185 | @Override 186 | public TypedQuery createNamedQuery(String s, Class aClass) { 187 | return null; 188 | } 189 | 190 | @Override 191 | public Query createNativeQuery(String s) { 192 | return null; 193 | } 194 | 195 | @Override 196 | public Query createNativeQuery(String s, Class aClass) { 197 | return null; 198 | } 199 | 200 | @Override 201 | public Query createNativeQuery(String s, String s1) { 202 | return null; 203 | } 204 | 205 | @Override 206 | public StoredProcedureQuery createNamedStoredProcedureQuery(String s) { 207 | return null; 208 | } 209 | 210 | @Override 211 | public StoredProcedureQuery createStoredProcedureQuery(String s) { 212 | return null; 213 | } 214 | 215 | @Override 216 | public StoredProcedureQuery createStoredProcedureQuery(String s, Class... classes) { 217 | return null; 218 | } 219 | 220 | @Override 221 | public StoredProcedureQuery createStoredProcedureQuery(String s, String... strings) { 222 | return null; 223 | } 224 | 225 | @Override 226 | public void joinTransaction() { 227 | 228 | } 229 | 230 | @Override 231 | public boolean isJoinedToTransaction() { 232 | return false; 233 | } 234 | 235 | @Override 236 | public T unwrap(Class aClass) { 237 | return null; 238 | } 239 | 240 | @Override 241 | public Object getDelegate() { 242 | return null; 243 | } 244 | 245 | @Override 246 | public void close() { 247 | 248 | } 249 | 250 | @Override 251 | public boolean isOpen() { 252 | return false; 253 | } 254 | 255 | @Override 256 | public EntityTransaction getTransaction() { 257 | return null; 258 | } 259 | 260 | @Override 261 | public EntityManagerFactory getEntityManagerFactory() { 262 | return null; 263 | } 264 | 265 | @Override 266 | public CriteriaBuilder getCriteriaBuilder() { 267 | return null; 268 | } 269 | 270 | @Override 271 | public Metamodel getMetamodel() { 272 | return null; 273 | } 274 | 275 | @Override 276 | public EntityGraph createEntityGraph(Class aClass) { 277 | return null; 278 | } 279 | 280 | @Override 281 | public EntityGraph createEntityGraph(String s) { 282 | return null; 283 | } 284 | 285 | @Override 286 | public EntityGraph getEntityGraph(String s) { 287 | return null; 288 | } 289 | 290 | @Override 291 | public List> getEntityGraphs(Class aClass) { 292 | return null; 293 | } 294 | }; 295 | } 296 | 297 | @Override 298 | public void close() { 299 | 300 | } 301 | 302 | public TicketEntity saveTicketStatus(String ticket, Number expireSeconds, String status) { 303 | logger.info(String.format("saveTicketStatus by %s%n%s%n", ticket, expireSeconds, status)); 304 | 305 | var entity = new TicketEntity(); 306 | entity.setId(UUID.randomUUID().toString()); 307 | entity.setTicket(ticket); 308 | entity.setStatus(status); 309 | entity.setExpireSeconds(expireSeconds); 310 | entity.setTicketCreatedAt(System.currentTimeMillis() / 1000L); 311 | em.persist(entity); 312 | 313 | return entity; 314 | } 315 | 316 | public TicketEntity getTicketStatus(String ticket) { 317 | logger.info(String.format("getTicketStatus by %s%n", ticket)); 318 | 319 | var ticketEntity = em.find(TicketEntity.class, ticket); 320 | 321 | logger.info(String.format("ticketEntity is %s%n", ticketEntity)); 322 | 323 | return ticketEntity; 324 | } 325 | 326 | public TicketEntity saveTicketStatus(TicketEntity ticket) { 327 | logger.info(String.format("saveTicketStatus by %s%n", ticket)); 328 | 329 | if (Objects.equals(ticket.getStatus(), "expired")) { 330 | em.remove(ticket); 331 | } else { 332 | em.persist(ticket); 333 | } 334 | 335 | return ticket; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/Endpoint.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin; 2 | 3 | import java.util.Map; 4 | import java.util.Objects; 5 | 6 | import org.keycloak.OAuth2Constants; 7 | import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; 8 | import org.keycloak.broker.provider.BrokeredIdentityContext; 9 | import org.keycloak.broker.provider.IdentityProvider; 10 | import org.keycloak.broker.provider.util.SimpleHttp; 11 | import org.keycloak.common.ClientConnection; 12 | import org.keycloak.events.Errors; 13 | import org.keycloak.events.EventBuilder; 14 | import org.keycloak.events.EventType; 15 | import org.keycloak.models.RealmModel; 16 | import org.keycloak.services.ErrorPage; 17 | import org.keycloak.services.messages.Messages; 18 | import org.keycloak.social.weixin.helpers.UserAgentHelper; 19 | import org.keycloak.social.weixin.helpers.WMPHelper; 20 | 21 | import jakarta.ws.rs.GET; 22 | import jakarta.ws.rs.QueryParam; 23 | import jakarta.ws.rs.WebApplicationException; 24 | import jakarta.ws.rs.core.Context; 25 | import jakarta.ws.rs.core.Response; 26 | 27 | public class Endpoint extends WeiXinIdentityProvider { 28 | 29 | private final WeiXinIdentityProvider weiXinIdentityProvider; 30 | protected IdentityProvider.AuthenticationCallback callback; 31 | protected RealmModel realm; 32 | protected EventBuilder event; 33 | 34 | @Context 35 | protected ClientConnection clientConnection; 36 | 37 | @Context 38 | protected org.keycloak.http.HttpRequest request; 39 | 40 | public Endpoint(WeiXinIdentityProvider weiXinIdentityProvider, IdentityProvider.AuthenticationCallback callback, RealmModel realm, EventBuilder event) { 41 | super(weiXinIdentityProvider.session, weiXinIdentityProvider.getConfig()); 42 | 43 | this.weiXinIdentityProvider = weiXinIdentityProvider; 44 | this.callback = callback; 45 | this.realm = realm; 46 | this.event = event; 47 | } 48 | 49 | @GET 50 | public Response authResponse(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_STATE) String state, 51 | @QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_CODE) String authorizationCode, @QueryParam(OAuth2Constants.ERROR) String error, @QueryParam(OAuth2Constants.SCOPE_OPENID) String openid, @QueryParam("client_data") String clientData, @QueryParam("clientId") String clientId, @QueryParam("tabId") String tabId) { 52 | AbstractOAuth2IdentityProvider.logger.info(String.format("OAUTH2_PARAMETER_CODE = %s, %s, %s, %s, %s", authorizationCode, error, openid, clientId, tabId)); 53 | var wechatLoginType = WechatLoginType.FROM_PC_QR_CODE_SCANNING; 54 | 55 | String ua = weiXinIdentityProvider.session.getContext().getRequestHeaders().getHeaderString("user-agent").toLowerCase(); 56 | if (UserAgentHelper.isWechatBrowser(ua)) { 57 | AbstractOAuth2IdentityProvider.logger.info("user-agent=wechat"); 58 | wechatLoginType = WechatLoginType.FROM_WECHAT_BROWSER; 59 | } 60 | 61 | if (error != null) { 62 | if (error.equals(AbstractOAuth2IdentityProvider.ACCESS_DENIED)) { 63 | AbstractOAuth2IdentityProvider.logger.error(AbstractOAuth2IdentityProvider.ACCESS_DENIED + " for broker login " + weiXinIdentityProvider.getConfig().getProviderId() + " " + state); 64 | return callback.cancelled(weiXinIdentityProvider.getConfig()); 65 | } else { 66 | AbstractOAuth2IdentityProvider.logger.error(error + " for broker login " + weiXinIdentityProvider.getConfig().getProviderId()); 67 | return callback.error(state + " " + Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR); 68 | } 69 | } 70 | 71 | try { 72 | BrokeredIdentityContext federatedIdentity; 73 | 74 | if (openid != null) { 75 | // TODO: use ticket here instead, and then use this ticket to get openid from sso.jiwai.win 76 | federatedIdentity = weiXinIdentityProvider.customAuth.auth(openid, wechatLoginType); 77 | 78 | setFederatedIdentity(state, federatedIdentity, weiXinIdentityProvider.customAuth.accessToken); 79 | 80 | return authenticated(federatedIdentity); 81 | } 82 | 83 | if (authorizationCode != null) { 84 | if (state == null) { 85 | wechatLoginType = WechatLoginType.FROM_WECHAT_MINI_PROGRAM; 86 | AbstractOAuth2IdentityProvider.logger.info("response from wmp with code = " + authorizationCode); 87 | } 88 | 89 | var responses = generateTokenRequest(authorizationCode, wechatLoginType); 90 | var response = responses[0].asString(); 91 | var accessTokenResponse = responses[1] != null ? responses[1].asString() : ""; 92 | AbstractOAuth2IdentityProvider.logger.info("response from auth code = " + response + ", " + accessTokenResponse); 93 | federatedIdentity = weiXinIdentityProvider.getFederatedIdentity(response, wechatLoginType, accessTokenResponse); 94 | 95 | setFederatedIdentity(Objects.requireNonNullElse(state, WMPHelper.createStateForWMP(clientId, tabId, clientData)), federatedIdentity, response); 96 | 97 | return authenticated(federatedIdentity); 98 | } 99 | } catch (WebApplicationException e) { 100 | return e.getResponse(); 101 | } catch (Exception e) { 102 | AbstractOAuth2IdentityProvider.logger.error("Failed to make identity provider (weixin) oauth callback", e); 103 | } 104 | event.event(EventType.LOGIN); 105 | event.error(Errors.IDENTITY_PROVIDER_LOGIN_FAILURE); 106 | return ErrorPage.error(weiXinIdentityProvider.session, null, Response.Status.BAD_GATEWAY, 107 | Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR); 108 | } 109 | 110 | private Response authenticated(BrokeredIdentityContext federatedIdentity) { 111 | var weiXinIdentityBrokerService 112 | = new WeiXinIdentityBrokerService(realm); 113 | weiXinIdentityBrokerService.init(weiXinIdentityProvider.session, clientConnection, event); 114 | 115 | return weiXinIdentityBrokerService.authenticated(federatedIdentity); 116 | } 117 | 118 | public void setFederatedIdentity(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_STATE) String state, BrokeredIdentityContext federatedIdentity, String accessToken) { 119 | if (weiXinIdentityProvider.getConfig().isStoreToken()) { 120 | if (federatedIdentity.getToken() == null) { 121 | federatedIdentity.setToken(accessToken); 122 | } 123 | } 124 | 125 | //federatedIdentity.setIdpConfig(weiXinIdentityProvider.getConfig()); 126 | federatedIdentity.setIdp(weiXinIdentityProvider); 127 | federatedIdentity.setContextData(Map.of("state", Objects.requireNonNullElse(state, "wmp"))); 128 | } 129 | 130 | public SimpleHttp[] generateTokenRequest(String authorizationCode, WechatLoginType wechatLoginType) { 131 | AbstractOAuth2IdentityProvider.logger.info(String.format("generateTokenRequest, code = %s, loginType = %s", authorizationCode, wechatLoginType)); 132 | if (WechatLoginType.FROM_WECHAT_BROWSER.equals(wechatLoginType)) { 133 | var mobileMpClientId = weiXinIdentityProvider.getConfig().getClientId(); 134 | var mobileMpClientSecret = weiXinIdentityProvider.getConfig().getClientSecret(); 135 | 136 | AbstractOAuth2IdentityProvider.logger.info(String.format("from wechat browser, posting to %s for fetching token, with mobileMpClientId = %s, mobileMpClientSecret = %s", weiXinIdentityProvider.getConfig().getTokenUrl(), mobileMpClientId, mobileMpClientSecret)); 137 | 138 | return new SimpleHttp[]{SimpleHttp.doPost(weiXinIdentityProvider.getConfig().getTokenUrl(), weiXinIdentityProvider.session) 139 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_CODE, authorizationCode) 140 | .param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_ID, mobileMpClientId) 141 | .param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_SECRET, mobileMpClientSecret) 142 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI, weiXinIdentityProvider.getConfig().getConfig().get(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI)) 143 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_GRANT_TYPE, AbstractOAuth2IdentityProvider.OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE), null}; 144 | } 145 | 146 | if (WechatLoginType.FROM_WECHAT_MINI_PROGRAM.equals(wechatLoginType)) { 147 | AbstractOAuth2IdentityProvider.logger.info(String.format("from wechat mini program, posting to %s", WeiXinIdentityProvider.WMP_AUTH_URL)); 148 | var wechatSession = SimpleHttp.doGet(WeiXinIdentityProvider.WMP_AUTH_URL, weiXinIdentityProvider.session).param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_ID, weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.WMP_APP_ID)).param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_SECRET, weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.WMP_APP_SECRET)).param("js_code", authorizationCode).param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_GRANT_TYPE, AbstractOAuth2IdentityProvider.OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE); 149 | 150 | var tokenRes = SimpleHttp.doGet(String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential" 151 | + "&appid=%s&secret=%s", weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.WMP_APP_ID), weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.WMP_APP_SECRET)), 152 | weiXinIdentityProvider.session); 153 | 154 | return new SimpleHttp[]{wechatSession, tokenRes}; 155 | } 156 | 157 | var isOpenClientEnabled = weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.OPEN_CLIENT_ENABLED); 158 | 159 | if (isOpenClientEnabled.equals("true")) { 160 | return new SimpleHttp[]{SimpleHttp.doPost(weiXinIdentityProvider.getConfig().getTokenUrl(), weiXinIdentityProvider.session).param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_CODE, authorizationCode) 161 | .param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_ID, weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.OPEN_CLIENT_ID)) 162 | .param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_SECRET, weiXinIdentityProvider.getConfig().getConfig().get(WeiXinIdentityProvider.OPEN_CLIENT_SECRET)) 163 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI, weiXinIdentityProvider.getConfig().getConfig().get(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI)) 164 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_GRANT_TYPE, AbstractOAuth2IdentityProvider.OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE), null}; 165 | } 166 | 167 | return new SimpleHttp[]{SimpleHttp.doPost(weiXinIdentityProvider.getConfig().getTokenUrl(), weiXinIdentityProvider.session).param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_CODE, authorizationCode) 168 | .param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_ID, weiXinIdentityProvider.getConfig().getClientId()) 169 | .param(WeiXinIdentityProvider.OAUTH2_PARAMETER_CLIENT_SECRET, weiXinIdentityProvider.getConfig().getClientSecret()) 170 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI, weiXinIdentityProvider.getConfig().getConfig().get(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI)) 171 | .param(AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_GRANT_TYPE, AbstractOAuth2IdentityProvider.OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE), null}; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/resources/QrCodeResourceProvider.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.resources; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import jakarta.ws.rs.*; 5 | import jakarta.ws.rs.core.MediaType; 6 | import jakarta.ws.rs.core.Response; 7 | import lombok.SneakyThrows; 8 | import org.apache.commons.collections4.map.HashedMap; 9 | import org.jboss.logging.Logger; 10 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 11 | import org.keycloak.models.IdentityProviderModel; 12 | import org.keycloak.models.IdentityProviderStorageProvider; 13 | import org.keycloak.models.KeycloakSession; 14 | import org.keycloak.services.resource.RealmResourceProvider; 15 | import org.keycloak.social.weixin.WeiXinIdentityProvider; 16 | import org.keycloak.social.weixin.cache.TicketStatusProvider; 17 | import org.w3c.dom.Document; 18 | import org.xml.sax.InputSource; 19 | 20 | import javax.xml.parsers.DocumentBuilder; 21 | import javax.xml.parsers.DocumentBuilderFactory; 22 | import java.io.StringReader; 23 | import java.util.Map; 24 | import java.util.Objects; 25 | 26 | import static org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider.OAUTH2_PARAMETER_REDIRECT_URI; 27 | import static org.keycloak.social.weixin.helpers.WechatMpHelper.isWechatMpMessage; 28 | 29 | public class QrCodeResourceProvider implements RealmResourceProvider { 30 | private final KeycloakSession session; 31 | protected static final Logger logger = Logger.getLogger(QrCodeResourceProvider.class); 32 | private final TicketStatusProvider ticketStatusProvider; 33 | 34 | public QrCodeResourceProvider(KeycloakSession session) { 35 | this.session = session; 36 | this.ticketStatusProvider = new TicketStatusProvider(session, null); 37 | } 38 | 39 | @Override 40 | public Object getResource() { 41 | return this; 42 | } 43 | 44 | @Override 45 | public void close() { 46 | 47 | } 48 | 49 | @GET 50 | @Path("hello") 51 | @Produces(MediaType.APPLICATION_JSON) 52 | public Response helloAnonymous() { 53 | logger.info("hello"); 54 | return Response.ok(Map.of("hello", session.getContext().getRealm().getName())).build(); 55 | } 56 | 57 | @GET 58 | @Path("mp-qr") 59 | @Produces(MediaType.TEXT_HTML + ";charset=UTF-8") 60 | public Response mpQrUrl(@QueryParam("ticket") String ticket, @QueryParam("qr-code-url") String qrCodeUrl, @QueryParam("state") String state, @QueryParam(OAUTH2_PARAMETER_REDIRECT_URI) String redirectUri) { 61 | logger.info("展示一个 HTML 页面,该页面使用 React 展示一个组件,它调用一个后端 API,得到一个带参二维码 URL,并将该 URL 作为 img 的 src 属性值"); 62 | 63 | var host = session.getContext().getUri().getBaseUri().toString(); 64 | var realmName = session.getContext().getRealm().getName(); 65 | var accountRedirectUri = host + "/realms/" + realmName + "/account"; 66 | 67 | logger.info(String.format("host is %s, realmName is %s", host, realmName)); 68 | 69 | var template = """ 70 | 71 | 72 | 73 | 74 | QR Code Page 75 | 100 | 101 | 102 |

103 |

请使用微信扫描下方二维码

104 | %s 105 |

等待扫码...

106 |
107 | 134 | 135 | 136 | 145 | 146 | 147 | """; 148 | 149 | String htmlContent = String.format(template, qrCodeUrl, ticket, ticket, redirectUri, state, host, realmName, accountRedirectUri); 150 | 151 | // 返回包含HTML内容的响应 152 | return Response.ok(htmlContent, MediaType.TEXT_HTML_TYPE).build(); 153 | } 154 | 155 | @SneakyThrows 156 | @GET 157 | @Path("mp-qr-scan-status") 158 | @Produces(MediaType.APPLICATION_JSON) 159 | public Response mpQrScanStatus(@QueryParam("ticket") String ticket) { 160 | logger.info("查询二维码扫描状态"); 161 | 162 | var ticketEntity = this.ticketStatusProvider.getTicketStatus(ticket); 163 | if (ticketEntity == null) { 164 | logger.warn(String.format("ticket is not found or expired, {%s}", ticket)); 165 | 166 | return Response.ok(Map.of("status", "not_found")).build(); 167 | } 168 | 169 | var expireSeconds = ticketEntity.getExpireSeconds(); 170 | var ticketCreatedAt = ticketEntity.getTicketCreatedAt(); 171 | var status = ticketEntity.getStatus(); 172 | var openid = ticketEntity.getOpenid(); 173 | var scannedAt = ticketEntity.getScannedAt(); 174 | 175 | if (expireSeconds.longValue() < (System.currentTimeMillis() / 1000 - ticketCreatedAt.longValue())) { 176 | status = "expired"; 177 | 178 | ticketEntity.setStatus(status); 179 | this.ticketStatusProvider.saveTicketStatus(ticketEntity); 180 | } 181 | 182 | logger.info(String.format("ticket is %s%n, status is %s%n, openid is %s", ticket, status, openid)); 183 | Map data = new HashedMap<>(); 184 | data.put("ticket", ticket); 185 | data.put("expireSeconds", expireSeconds.toString()); 186 | data.put("ticketCreatedAt", ticketCreatedAt.toString()); 187 | data.put("status", status); 188 | data.put("openid", openid); 189 | data.put("scannedAt", Objects.toString(scannedAt, null)); 190 | 191 | var objectMapper = new ObjectMapper(); 192 | var json = objectMapper.writeValueAsString(data); 193 | return Response.ok(json, MediaType.APPLICATION_JSON).build(); 194 | } 195 | 196 | @SneakyThrows 197 | @POST 198 | @Path("mp-qr-scan-status") 199 | @Consumes(MediaType.APPLICATION_XML) 200 | @Produces(MediaType.APPLICATION_JSON) 201 | public Response mpQrScanStatusScanned(String xmlData) { 202 | logger.info("查询二维码状态: " + xmlData); 203 | 204 | DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 205 | DocumentBuilder builder = factory.newDocumentBuilder(); 206 | Document document = builder.parse(new InputSource(new StringReader(xmlData))); 207 | var root = document.getDocumentElement(); 208 | var xmlEvent = root.getElementsByTagName("Event").item(0).getTextContent(); 209 | 210 | if (!Objects.equals(xmlEvent, "SCAN")) { 211 | logger.info(String.format("ignoring not scanning event: {%s} != {%s}", xmlEvent, "SCAN")); 212 | return Response.ok(Map.of("status", "not_scanned")).build(); 213 | } 214 | 215 | var xmlTicket = root.getElementsByTagName("Ticket").item(0).getTextContent(); 216 | var xmlFromUserName = root.getElementsByTagName("FromUserName").item(0).getTextContent(); 217 | 218 | var ticketSaved = this.ticketStatusProvider.getTicketStatus(xmlTicket); 219 | if (ticketSaved == null) { 220 | logger.warn(String.format("ticket is not found, {%s}", xmlTicket)); 221 | return Response.ok(Map.of("status", "not_scanned")).build(); 222 | } 223 | 224 | ticketSaved.setStatus("scanned"); 225 | ticketSaved.setScannedAt(System.currentTimeMillis() / 1000L); 226 | ticketSaved.setOpenid(xmlFromUserName); 227 | 228 | this.ticketStatusProvider.saveTicketStatus(ticketSaved); 229 | 230 | return Response.ok("success").build(); 231 | } 232 | 233 | @SneakyThrows 234 | @POST 235 | @Path("message") 236 | @Consumes(MediaType.WILDCARD) 237 | @Produces(MediaType.APPLICATION_JSON) 238 | public Response message( 239 | @QueryParam("signature") String signature, 240 | @QueryParam("timestamp") String timestamp, 241 | @QueryParam("nonce") String nonce, 242 | String xmlData 243 | ) { 244 | logger.info("接收微信消息和事件" + xmlData); 245 | logger.info("查询参数: signature=" + signature + ", timestamp=" + timestamp + ", nonce=" + nonce); 246 | 247 | // 获取配置的WECHAT_MP_APP_TOKEN 248 | IdentityProviderStorageProvider idpStorage = session.getProvider(IdentityProviderStorageProvider.class); 249 | IdentityProviderModel idpModel = idpStorage.getByAlias("weixin"); 250 | if (idpModel == null) { 251 | return Response.status(Response.Status.NOT_FOUND) 252 | .entity("Identity Provider not found") 253 | .build(); 254 | } 255 | OAuth2IdentityProviderConfig config = new OAuth2IdentityProviderConfig(idpModel); 256 | String token = config.getConfig().get(WeiXinIdentityProvider.WECHAT_MP_APP_TOKEN); 257 | 258 | if (token != null && !token.isEmpty()) { 259 | // 使用配置的token进行验证 260 | if (!isWechatMpMessage(token, signature, timestamp, nonce)) { 261 | logger.warn("签名验证失败"); 262 | return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid signature").build(); 263 | } 264 | } else { 265 | // 如果没有配置token,使用默认的验证方式 266 | if (!isWechatMpMessage(signature, timestamp, nonce)) { 267 | logger.warn("签名验证失败"); 268 | return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid signature").build(); 269 | } 270 | } 271 | 272 | DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 273 | DocumentBuilder builder = factory.newDocumentBuilder(); 274 | Document document = builder.parse(new InputSource(new StringReader(xmlData))); 275 | var root = document.getDocumentElement(); 276 | var xmlEvent = root.getElementsByTagName("Event").item(0).getTextContent(); 277 | 278 | if (!Objects.equals(xmlEvent, "SCAN") && !Objects.equals(xmlEvent, "subscribe")) { 279 | logger.info(String.format("ignoring not scanning event: {%s} != {%s}", xmlEvent, "SCAN")); 280 | 281 | return Response.ok("success").build(); 282 | // return Response.ok(Map.of("status", "not_scanned")).build(); 283 | } 284 | 285 | var xmlTicket = root.getElementsByTagName("Ticket").item(0).getTextContent(); 286 | var xmlFromUserName = root.getElementsByTagName("FromUserName").item(0).getTextContent(); 287 | 288 | var ticketSaved = this.ticketStatusProvider.getTicketStatus(xmlTicket); 289 | if (ticketSaved == null) { 290 | logger.warn(String.format("ticket is not found, {%s}", xmlTicket)); 291 | // return Response.ok("success").build(); 292 | return Response.ok(Map.of("status", "ticket_not_found")).build(); 293 | } 294 | 295 | ticketSaved.setStatus("scanned"); 296 | ticketSaved.setScannedAt(System.currentTimeMillis() / 1000L); 297 | ticketSaved.setOpenid(xmlFromUserName); 298 | 299 | this.ticketStatusProvider.saveTicketStatus(ticketSaved); 300 | 301 | return Response.ok("success").build(); 302 | } 303 | 304 | @SneakyThrows 305 | @GET 306 | @Path("message") 307 | @Produces(MediaType.APPLICATION_JSON) 308 | public Response message( 309 | @QueryParam("echostr") String echostr, @QueryParam("signature") String signature, 310 | @QueryParam("timestamp") String timestamp, 311 | @QueryParam("nonce") String nonce, 312 | String xmlData) { 313 | 314 | logger.info("接收到微信服务器发来的事件: " + xmlData); 315 | logger.info("查询参数: signature=" + signature + ", timestamp=" + timestamp + ", nonce=" + nonce); 316 | 317 | // 获取配置的WECHAT_MP_APP_TOKEN 318 | IdentityProviderStorageProvider idpStorage = session.getProvider(IdentityProviderStorageProvider.class); 319 | IdentityProviderModel idpModel = idpStorage.getByAlias("weixin"); 320 | if (idpModel == null) { 321 | return Response.status(Response.Status.NOT_FOUND) 322 | .entity("Identity Provider not found") 323 | .build(); 324 | } 325 | OAuth2IdentityProviderConfig config = new OAuth2IdentityProviderConfig(idpModel); 326 | String token = config.getConfig().get(WeiXinIdentityProvider.WECHAT_MP_APP_TOKEN); 327 | 328 | if (token != null && !token.isEmpty()) { 329 | // 使用配置的token进行验证 330 | if (!isWechatMpMessage(token, signature, timestamp, nonce)) { 331 | logger.warn("签名验证失败"); 332 | return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid signature").build(); 333 | } 334 | } else { 335 | // 如果没有配置token,使用默认的验证方式 336 | if (!isWechatMpMessage(signature, timestamp, nonce)) { 337 | logger.warn("签名验证失败"); 338 | return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid signature").build(); 339 | } 340 | } 341 | 342 | return Response.ok(echostr).build(); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/main/java/org/keycloak/social/weixin/WeiXinIdentityProvider.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Red Hat, Inc. and/or its affiliates 3 | * and other contributors as indicated by the @author tags. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | package org.keycloak.social.weixin; 18 | 19 | import java.io.IOException; 20 | import java.net.URI; 21 | import java.util.Objects; 22 | import java.util.UUID; 23 | 24 | import jakarta.ws.rs.core.*; 25 | import org.jboss.logging.Logger; 26 | import org.keycloak.OAuth2Constants; 27 | import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; 28 | import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; 29 | import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; 30 | import org.keycloak.broker.provider.AuthenticationRequest; 31 | import org.keycloak.broker.provider.BrokeredIdentityContext; 32 | import org.keycloak.broker.provider.IdentityBrokerException; 33 | import org.keycloak.broker.provider.util.SimpleHttp; 34 | import org.keycloak.broker.social.SocialIdentityProvider; 35 | import org.keycloak.events.EventBuilder; 36 | import org.keycloak.models.KeycloakSession; 37 | import org.keycloak.models.RealmModel; 38 | import org.keycloak.protocol.oidc.OIDCLoginProtocol; 39 | 40 | import com.fasterxml.jackson.databind.JsonNode; 41 | import com.fasterxml.jackson.databind.ObjectMapper; 42 | import org.keycloak.social.weixin.egress.wechat.mp.WechatMpApi; 43 | import org.keycloak.social.weixin.egress.wechat.mp.models.ActionInfo; 44 | import org.keycloak.social.weixin.egress.wechat.mp.models.Scene; 45 | import org.keycloak.social.weixin.egress.wechat.mp.models.TicketRequest; 46 | import org.keycloak.social.weixin.helpers.UserAgentHelper; 47 | 48 | public class WeiXinIdentityProvider extends AbstractOAuth2IdentityProvider 49 | implements SocialIdentityProvider { 50 | private static final Logger wxlogger = Logger.getLogger(WeiXinIdentityProvider.class); 51 | 52 | private static final String TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token"; 53 | 54 | public static final String OPEN_AUTH_URL = "https://open.weixin.qq.com/connect/qrconnect"; 55 | public static final String OPEN_DEFAULT_SCOPE = "snsapi_login"; 56 | public static final String OAUTH2_PARAMETER_CLIENT_ID = "appid"; 57 | public static final String OAUTH2_PARAMETER_CLIENT_SECRET = "secret"; 58 | 59 | public static final String OPEN_CLIENT_ID = "openClientId"; 60 | public static final String OPEN_CLIENT_SECRET = "openClientSecret"; 61 | public static final String OPEN_CLIENT_ENABLED = "openClientEnabled"; 62 | 63 | public static final String WECHAT_MOBILE_AUTH_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"; 64 | public static final String WECHAT_MP_DEFAULT_SCOPE = "snsapi_userinfo"; 65 | public static final String CUSTOMIZED_LOGIN_URL_FOR_PC = "customizedLoginUrl"; 66 | public static final String WECHAT_MP_APP_ID = "clientId2"; 67 | public static final String WECHAT_MP_APP_SECRET = "clientSecret2"; 68 | 69 | public static final String WECHAT_MP_APP_TOKEN = "clientToken"; 70 | 71 | public static final String PROFILE_URL = "https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN"; 72 | 73 | public static final String WMP_APP_ID = "wmpClientId"; 74 | public static final String WMP_APP_SECRET = "wmpClientSecret"; 75 | public static final String WMP_AUTH_URL = "https://api.weixin.qq.com/sns/jscode2session"; 76 | 77 | public static final String OPENID = "openid"; 78 | public static final String WECHATFLAG = "micromessenger"; 79 | public final WeixinIdentityCustomAuth customAuth; 80 | protected KeycloakSession session; 81 | 82 | public WeiXinIdentityProvider(KeycloakSession session, OAuth2IdentityProviderConfig config) { 83 | super(session, config); 84 | config.setAuthorizationUrl(OPEN_AUTH_URL); 85 | config.setTokenUrl(TOKEN_URL); 86 | 87 | customAuth = new WeixinIdentityCustomAuth(session, config, this); 88 | this.session = session; 89 | } 90 | 91 | public WeiXinIdentityProvider(KeycloakSession session, WeixinIdentityProviderConfig config) { 92 | super(session, config); 93 | config.setAuthorizationUrl(OPEN_AUTH_URL); 94 | config.setTokenUrl(TOKEN_URL); 95 | config.setUserInfoUrl(PROFILE_URL); 96 | 97 | customAuth = new WeixinIdentityCustomAuth(session, config, this); 98 | this.session = session; 99 | } 100 | 101 | @Override 102 | public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) { 103 | wxlogger.info(String.format("callback event = %s", event)); 104 | return new org.keycloak.social.weixin.Endpoint(this, callback, realm, event); 105 | } 106 | 107 | @Override 108 | protected boolean supportsExternalExchange() { 109 | return true; 110 | } 111 | 112 | @Override 113 | protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { 114 | String unionId = getJsonProperty(profile, "unionid"); 115 | var openId = getJsonProperty(profile, "openid"); 116 | var nickname = getJsonProperty(profile, "nickname"); 117 | var avatar = getJsonProperty(profile, "headimgurl"); 118 | var externalUserId = unionId != null && !unionId.isEmpty() ? unionId : openId; 119 | 120 | BrokeredIdentityContext user = new BrokeredIdentityContext(externalUserId,super.getConfig()); 121 | 122 | user.setUsername(externalUserId); 123 | user.setBrokerUserId(externalUserId); 124 | user.setModelUsername(externalUserId); 125 | user.setName(nickname); 126 | 127 | wxlogger.info("set user avatar to:" + avatar); 128 | user.setUserAttribute("avatar", avatar); 129 | //user.setIdpConfig(getConfig()); 130 | user.setIdp(this); 131 | AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); 132 | 133 | return user; 134 | } 135 | 136 | public BrokeredIdentityContext getFederatedIdentity(String response, WechatLoginType wechatLoginType, String response2) { 137 | var accessToken = wechatLoginType.equals(WechatLoginType.FROM_WECHAT_MINI_PROGRAM) ? extractTokenFromResponse(response2, getAccessTokenResponseParameter()) : extractTokenFromResponse(response, getAccessTokenResponseParameter()); 138 | 139 | if (accessToken == null) { 140 | throw new IdentityBrokerException("No access token available in OAuth server response: " + response); 141 | } 142 | 143 | BrokeredIdentityContext context = null; 144 | try { 145 | JsonNode profile; 146 | if (WechatLoginType.FROM_WECHAT_BROWSER.equals(wechatLoginType) || 147 | WechatLoginType.FROM_PC_QR_CODE_SCANNING.equals(wechatLoginType)) { 148 | String openid = extractTokenFromResponse(response, OPENID); 149 | String url = PROFILE_URL.replace("ACCESS_TOKEN", accessToken).replace("OPENID", openid); 150 | profile = SimpleHttp.doGet(url, session).asJson(); 151 | } else { 152 | profile = new ObjectMapper().readTree(response); 153 | } 154 | wxlogger.info("get userInfo =" + profile.toString()); 155 | context = extractIdentityFromProfile(null, profile); 156 | } catch (IOException e) { 157 | wxlogger.error(e); 158 | } 159 | 160 | assert context != null; 161 | 162 | context.getContextData().put(FEDERATED_ACCESS_TOKEN, accessToken); 163 | return context; 164 | } 165 | 166 | @Override 167 | public Response performLogin(AuthenticationRequest request) { 168 | wxlogger.info(String.format("performing Login = %s", request != null && request.getUriInfo() != null ? request.getUriInfo().getAbsolutePath().toString() : "null")); 169 | try { 170 | URI authorizationUrl = createAuthorizationUrl(Objects.requireNonNull(request)).build(); 171 | wxlogger.info(String.format("authorizationUrl = %s", authorizationUrl.toString())); 172 | 173 | String ua = request.getSession().getContext().getRequestHeaders().getHeaderString("user-agent").toLowerCase(); 174 | wxlogger.info(String.format("user-agent = %s", ua)); 175 | 176 | if (UserAgentHelper.isWechatBrowser(ua)) { 177 | URI location = URI.create(String.format("%s#wechat_redirect", authorizationUrl)); 178 | wxlogger.info(String.format("see other %s", location)); 179 | 180 | return Response.seeOther(location).build(); 181 | } 182 | 183 | wxlogger.info(String.format("see other %s", authorizationUrl)); 184 | 185 | return Response.seeOther(authorizationUrl).build(); 186 | } catch (Exception e) { 187 | throw new IdentityBrokerException("Could not create authentication request because " + e, 188 | e); 189 | } 190 | } 191 | 192 | @Override 193 | protected String getDefaultScopes() { 194 | return OPEN_DEFAULT_SCOPE; 195 | } 196 | 197 | 198 | @Override 199 | protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) { 200 | final UriBuilder uriBuilder; 201 | String ua = request.getSession().getContext().getRequestHeaders().getHeaderString("user-agent").toLowerCase(); 202 | wxlogger.info(String.format("creating auth url from %s", ua)); 203 | 204 | if (UserAgentHelper.isWechatBrowser(ua)) {// 是微信浏览器 205 | wxlogger.info("----------wechat"); 206 | uriBuilder = UriBuilder.fromUri(WECHAT_MOBILE_AUTH_URL); 207 | uriBuilder.queryParam(OAUTH2_PARAMETER_SCOPE, WECHAT_MP_DEFAULT_SCOPE) 208 | .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded()) 209 | .queryParam(OAUTH2_PARAMETER_RESPONSE_TYPE, "code") 210 | .queryParam(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId()) 211 | .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri()); 212 | 213 | return uriBuilder; 214 | } else { 215 | var config = getConfig(); 216 | if (config instanceof WeixinIdentityProviderConfig) { 217 | var customizedLoginUrlForPc = ((WeixinIdentityProviderConfig) config).getCustomizedLoginUrlForPc(); 218 | if (config.getConfig().get(OPEN_CLIENT_ENABLED) != null && config.getConfig().get(OPEN_CLIENT_ENABLED).equals("true")) { 219 | wxlogger.info("----------open client enabled"); 220 | if (customizedLoginUrlForPc!=null){ 221 | uriBuilder = UriBuilder.fromUri(customizedLoginUrlForPc); 222 | }else { 223 | uriBuilder = UriBuilder.fromUri(OPEN_AUTH_URL); 224 | } 225 | 226 | uriBuilder.queryParam(OAUTH2_PARAMETER_SCOPE, OPEN_DEFAULT_SCOPE) 227 | .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded()) 228 | .queryParam(OAUTH2_PARAMETER_RESPONSE_TYPE, "code") 229 | .queryParam(OAUTH2_PARAMETER_CLIENT_ID, config.getConfig().get(OPEN_CLIENT_ID)) 230 | .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri()); 231 | 232 | return uriBuilder; 233 | } 234 | 235 | if (customizedLoginUrlForPc != null && !customizedLoginUrlForPc.isEmpty()) { 236 | wxlogger.info("----------customized login url for pc"); 237 | wxlogger.info("clientId: " + config.getConfig().get(WECHAT_MP_APP_ID)); 238 | wxlogger.info("state: " + request.getState().getEncoded()); 239 | 240 | uriBuilder = UriBuilder.fromUri(customizedLoginUrlForPc); 241 | 242 | uriBuilder.queryParam(OAUTH2_PARAMETER_SCOPE, WECHAT_MP_DEFAULT_SCOPE) 243 | .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded()) 244 | .queryParam(OAUTH2_PARAMETER_RESPONSE_TYPE, "code") 245 | .queryParam(OAUTH2_PARAMETER_CLIENT_ID, config.getConfig().get(WECHAT_MP_APP_ID)) 246 | .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri()); 247 | 248 | return uriBuilder; 249 | } else { 250 | wxlogger.info("未启用开放平台,且未配置自定义登录页面,则返回一个 html 页面,展示带参二维码"); 251 | uriBuilder = UriBuilder.fromUri("/realms/" + request.getRealm().getName() + "/QrCodeResourceProviderFactory/mp-qr"); 252 | 253 | var wechatApi = new WechatMpApi( 254 | config.getConfig().get(WECHAT_MP_APP_ID), 255 | config.getConfig().get(WECHAT_MP_APP_SECRET), 256 | session, 257 | request.getAuthenticationSession() 258 | ); 259 | 260 | var ticket = wechatApi.createTmpQrCode(new TicketRequest(2592000, "QR_STR_SCENE", new ActionInfo(new Scene("1")))).ticket; 261 | wxlogger.info("ticket = " + ticket); 262 | 263 | uriBuilder 264 | .queryParam("ticket", ticket) 265 | .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded()) 266 | .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri()) 267 | .queryParam("qr-code-url", "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + ticket) 268 | ; 269 | } 270 | } else { 271 | wxlogger.info("----------default"); 272 | wxlogger.info("clientId: " + config.getClientId()); 273 | uriBuilder = UriBuilder.fromUri(config.getAuthorizationUrl()); 274 | uriBuilder.queryParam(OAUTH2_PARAMETER_SCOPE, config.getDefaultScope()) 275 | .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded()) 276 | .queryParam(OAUTH2_PARAMETER_CLIENT_ID, config.getClientId()) 277 | .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri()); 278 | } 279 | } 280 | 281 | String loginHint = request.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM); 282 | wxlogger.info("loginHint = " + loginHint); 283 | if (getConfig().isLoginHint() && loginHint != null) { 284 | uriBuilder.queryParam(OIDCLoginProtocol.LOGIN_HINT_PARAM, loginHint); 285 | } 286 | 287 | String prompt = getConfig().getPrompt(); 288 | if (prompt == null || prompt.isEmpty()) { 289 | prompt = request.getAuthenticationSession().getClientNote(OAuth2Constants.PROMPT); 290 | } 291 | if (prompt != null) { 292 | uriBuilder.queryParam(OAuth2Constants.PROMPT, prompt); 293 | } 294 | 295 | String nonce = request.getAuthenticationSession().getClientNote(OIDCLoginProtocol.NONCE_PARAM); 296 | if (nonce == null || nonce.isEmpty()) { 297 | nonce = UUID.randomUUID().toString(); 298 | request.getAuthenticationSession().setClientNote(OIDCLoginProtocol.NONCE_PARAM, nonce); 299 | } 300 | uriBuilder.queryParam(OIDCLoginProtocol.NONCE_PARAM, nonce); 301 | 302 | String acr = request.getAuthenticationSession().getClientNote(OAuth2Constants.ACR_VALUES); 303 | if (acr != null) { 304 | uriBuilder.queryParam(OAuth2Constants.ACR_VALUES, acr); 305 | } 306 | 307 | return uriBuilder; 308 | } 309 | 310 | } 311 | -------------------------------------------------------------------------------- /src/test/java/org/keycloak/social/weixin/helpers/JsonHelperTest.java: -------------------------------------------------------------------------------- 1 | package org.keycloak.social.weixin.helpers; 2 | 3 | import org.junit.Assert; 4 | import org.junit.jupiter.api.Test; 5 | import org.keycloak.broker.provider.BrokeredIdentityContext; 6 | import org.keycloak.models.*; 7 | import org.keycloak.sessions.AuthenticationSessionModel; 8 | import org.keycloak.sessions.RootAuthenticationSessionModel; 9 | import org.keycloak.social.weixin.WMPUserSessionModel; 10 | import org.keycloak.social.weixin.helpers.JsonHelper; 11 | import org.keycloak.social.weixin.helpers.WMPHelper; 12 | 13 | import java.util.*; 14 | import java.util.function.*; 15 | import java.util.stream.*; 16 | 17 | class JsonHelperTest { 18 | 19 | @Test 20 | void stringify() { 21 | UserModel federatedUser = new UserModel() { 22 | @Override 23 | public String getId() { 24 | return "hello"; 25 | } 26 | 27 | @Override 28 | public String getUsername() { 29 | return "test user"; 30 | } 31 | 32 | @Override 33 | public void setUsername(String s) { 34 | 35 | } 36 | 37 | @Override 38 | public Long getCreatedTimestamp() { 39 | return null; 40 | } 41 | 42 | @Override 43 | public void setCreatedTimestamp(Long aLong) { 44 | 45 | } 46 | 47 | @Override 48 | public boolean isEnabled() { 49 | return false; 50 | } 51 | 52 | @Override 53 | public void setEnabled(boolean b) { 54 | 55 | } 56 | 57 | @Override 58 | public void setSingleAttribute(String s, String s1) { 59 | 60 | } 61 | 62 | @Override 63 | public void setAttribute(String s, List list) { 64 | 65 | } 66 | 67 | @Override 68 | public void removeAttribute(String s) { 69 | 70 | } 71 | 72 | @Override 73 | public String getFirstAttribute(String s) { 74 | return null; 75 | } 76 | 77 | @Override 78 | public Stream getAttributeStream(String s) { 79 | return null; 80 | } 81 | 82 | @Override 83 | public Map> getAttributes() { 84 | return null; 85 | } 86 | 87 | @Override 88 | public Stream getRequiredActionsStream() { 89 | return null; 90 | } 91 | 92 | @Override 93 | public void addRequiredAction(String s) { 94 | 95 | } 96 | 97 | @Override 98 | public void removeRequiredAction(String s) { 99 | 100 | } 101 | 102 | @Override 103 | public String getFirstName() { 104 | return null; 105 | } 106 | 107 | @Override 108 | public void setFirstName(String s) { 109 | 110 | } 111 | 112 | @Override 113 | public String getLastName() { 114 | return null; 115 | } 116 | 117 | @Override 118 | public void setLastName(String s) { 119 | 120 | } 121 | 122 | @Override 123 | public String getEmail() { 124 | return null; 125 | } 126 | 127 | @Override 128 | public void setEmail(String s) { 129 | 130 | } 131 | 132 | @Override 133 | public boolean isEmailVerified() { 134 | return false; 135 | } 136 | 137 | @Override 138 | public void setEmailVerified(boolean b) { 139 | 140 | } 141 | 142 | @Override 143 | public Stream getGroupsStream() { 144 | return new Stream() { 145 | @Override 146 | public Stream filter(Predicate predicate) { 147 | return Stream.empty(); 148 | } 149 | 150 | @Override 151 | public Stream map(Function mapper) { 152 | return Stream.empty(); 153 | } 154 | 155 | @Override 156 | public IntStream mapToInt(ToIntFunction mapper) { 157 | return IntStream.empty(); 158 | } 159 | 160 | @Override 161 | public LongStream mapToLong(ToLongFunction mapper) { 162 | return LongStream.empty(); 163 | } 164 | 165 | @Override 166 | public DoubleStream mapToDouble(ToDoubleFunction mapper) { 167 | return DoubleStream.empty(); 168 | } 169 | 170 | @Override 171 | public Stream flatMap(Function> mapper) { 172 | return Stream.empty(); 173 | } 174 | 175 | @Override 176 | public IntStream flatMapToInt(Function mapper) { 177 | return IntStream.empty(); 178 | } 179 | 180 | @Override 181 | public LongStream flatMapToLong(Function mapper) { 182 | return LongStream.empty(); 183 | } 184 | 185 | @Override 186 | public DoubleStream flatMapToDouble(Function mapper) { 187 | return DoubleStream.empty(); 188 | } 189 | 190 | @Override 191 | public Stream distinct() { 192 | return Stream.empty(); 193 | } 194 | 195 | @Override 196 | public Stream sorted() { 197 | return Stream.empty(); 198 | } 199 | 200 | @Override 201 | public Stream sorted(Comparator comparator) { 202 | return Stream.empty(); 203 | } 204 | 205 | @Override 206 | public Stream peek(Consumer action) { 207 | return Stream.empty(); 208 | } 209 | 210 | @Override 211 | public Stream limit(long maxSize) { 212 | return Stream.empty(); 213 | } 214 | 215 | @Override 216 | public Stream skip(long n) { 217 | return Stream.empty(); 218 | } 219 | 220 | @Override 221 | public void forEach(Consumer action) { 222 | 223 | } 224 | 225 | @Override 226 | public void forEachOrdered(Consumer action) { 227 | 228 | } 229 | 230 | @Override 231 | public Object[] toArray() { 232 | return new Object[0]; 233 | } 234 | 235 | @Override 236 | public A[] toArray(IntFunction generator) { 237 | return null; 238 | } 239 | 240 | @Override 241 | public GroupModel reduce(GroupModel identity, BinaryOperator accumulator) { 242 | return null; 243 | } 244 | 245 | @Override 246 | public Optional reduce(BinaryOperator accumulator) { 247 | return Optional.empty(); 248 | } 249 | 250 | @Override 251 | public U reduce(U identity, BiFunction accumulator, BinaryOperator combiner) { 252 | return null; 253 | } 254 | 255 | @Override 256 | public R collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner) { 257 | return null; 258 | } 259 | 260 | @Override 261 | public R collect(Collector collector) { 262 | return null; 263 | } 264 | 265 | @Override 266 | public Optional min(Comparator comparator) { 267 | return Optional.empty(); 268 | } 269 | 270 | @Override 271 | public Optional max(Comparator comparator) { 272 | return Optional.empty(); 273 | } 274 | 275 | @Override 276 | public long count() { 277 | return 0; 278 | } 279 | 280 | @Override 281 | public boolean anyMatch(Predicate predicate) { 282 | return false; 283 | } 284 | 285 | @Override 286 | public boolean allMatch(Predicate predicate) { 287 | return false; 288 | } 289 | 290 | @Override 291 | public boolean noneMatch(Predicate predicate) { 292 | return false; 293 | } 294 | 295 | @Override 296 | public Optional findFirst() { 297 | return Optional.empty(); 298 | } 299 | 300 | @Override 301 | public Optional findAny() { 302 | return Optional.empty(); 303 | } 304 | 305 | @Override 306 | public Iterator iterator() { 307 | return null; 308 | } 309 | 310 | @Override 311 | public Spliterator spliterator() { 312 | return null; 313 | } 314 | 315 | @Override 316 | public boolean isParallel() { 317 | return false; 318 | } 319 | 320 | @Override 321 | public Stream sequential() { 322 | return Stream.empty(); 323 | } 324 | 325 | @Override 326 | public Stream parallel() { 327 | return Stream.empty(); 328 | } 329 | 330 | @Override 331 | public Stream unordered() { 332 | return Stream.empty(); 333 | } 334 | 335 | @Override 336 | public Stream onClose(Runnable closeHandler) { 337 | return Stream.empty(); 338 | } 339 | 340 | @Override 341 | public void close() { 342 | 343 | } 344 | }; 345 | } 346 | 347 | @Override 348 | public void joinGroup(GroupModel groupModel) { 349 | 350 | } 351 | 352 | @Override 353 | public void leaveGroup(GroupModel groupModel) { 354 | 355 | } 356 | 357 | @Override 358 | public boolean isMemberOf(GroupModel groupModel) { 359 | return false; 360 | } 361 | 362 | @Override 363 | public String getFederationLink() { 364 | return null; 365 | } 366 | 367 | @Override 368 | public void setFederationLink(String s) { 369 | 370 | } 371 | 372 | @Override 373 | public String getServiceAccountClientLink() { 374 | return null; 375 | } 376 | 377 | @Override 378 | public void setServiceAccountClientLink(String s) { 379 | 380 | } 381 | 382 | @Override 383 | public SubjectCredentialManager credentialManager() { 384 | return null; 385 | } 386 | 387 | @Override 388 | public Stream getRealmRoleMappingsStream() { 389 | return null; 390 | } 391 | 392 | @Override 393 | public Stream getClientRoleMappingsStream(ClientModel clientModel) { 394 | return null; 395 | } 396 | 397 | @Override 398 | public boolean hasRole(RoleModel roleModel) { 399 | return false; 400 | } 401 | 402 | @Override 403 | public void grantRole(RoleModel roleModel) { 404 | 405 | } 406 | 407 | @Override 408 | public Stream getRoleMappingsStream() { 409 | return null; 410 | } 411 | 412 | @Override 413 | public void deleteRoleMapping(RoleModel roleModel) { 414 | 415 | } 416 | }; 417 | 418 | Assert.assertEquals("{\n" + 419 | " \"username\": \"test user\",\n" + 420 | " \"id\": \"hello\",\n" + 421 | " \"email\": null,\n" + 422 | " \"enabled\": false,\n" + 423 | " \"firstName\": null,\n" + 424 | " \"lastName\": null,\n" + 425 | " \"createdTimestamp\": null,\n" + 426 | " \"federationLink\": null,\n" + 427 | " \"serviceAccountClientLink\": null,\n" + 428 | " \"groupsCount\": 0,\n" + 429 | " \"attributes\": \"\"\n" + 430 | "}", JsonHelper.stringify(federatedUser, UserModel.class)); 431 | 432 | var user = WMPHelper.getUserSessionModel(new BrokeredIdentityContext("test", new IdentityProviderModel()), federatedUser, new AuthenticationSessionModel() { 433 | @Override 434 | public String getTabId() { 435 | return null; 436 | } 437 | 438 | @Override 439 | public RootAuthenticationSessionModel getParentSession() { 440 | return null; 441 | } 442 | 443 | @Override 444 | public Map getExecutionStatus() { 445 | return null; 446 | } 447 | 448 | @Override 449 | public void setExecutionStatus(String s, ExecutionStatus executionStatus) { 450 | 451 | } 452 | 453 | @Override 454 | public void clearExecutionStatus() { 455 | 456 | } 457 | 458 | @Override 459 | public UserModel getAuthenticatedUser() { 460 | return null; 461 | } 462 | 463 | @Override 464 | public void setAuthenticatedUser(UserModel userModel) { 465 | 466 | } 467 | 468 | @Override 469 | public Set getRequiredActions() { 470 | return null; 471 | } 472 | 473 | @Override 474 | public void addRequiredAction(String s) { 475 | 476 | } 477 | 478 | @Override 479 | public void removeRequiredAction(String s) { 480 | 481 | } 482 | 483 | @Override 484 | public void addRequiredAction(UserModel.RequiredAction requiredAction) { 485 | 486 | } 487 | 488 | @Override 489 | public void removeRequiredAction(UserModel.RequiredAction requiredAction) { 490 | 491 | } 492 | 493 | @Override 494 | public void setUserSessionNote(String s, String s1) { 495 | 496 | } 497 | 498 | @Override 499 | public Map getUserSessionNotes() { 500 | return null; 501 | } 502 | 503 | @Override 504 | public void clearUserSessionNotes() { 505 | 506 | } 507 | 508 | @Override 509 | public String getAuthNote(String s) { 510 | return null; 511 | } 512 | 513 | @Override 514 | public void setAuthNote(String s, String s1) { 515 | 516 | } 517 | 518 | @Override 519 | public void removeAuthNote(String s) { 520 | 521 | } 522 | 523 | @Override 524 | public void clearAuthNotes() { 525 | 526 | } 527 | 528 | @Override 529 | public String getClientNote(String s) { 530 | return null; 531 | } 532 | 533 | @Override 534 | public void setClientNote(String s, String s1) { 535 | 536 | } 537 | 538 | @Override 539 | public void removeClientNote(String s) { 540 | 541 | } 542 | 543 | @Override 544 | public Map getClientNotes() { 545 | return null; 546 | } 547 | 548 | @Override 549 | public void clearClientNotes() { 550 | 551 | } 552 | 553 | @Override 554 | public Set getClientScopes() { 555 | return null; 556 | } 557 | 558 | @Override 559 | public void setClientScopes(Set set) { 560 | 561 | } 562 | 563 | @Override 564 | public String getRedirectUri() { 565 | return null; 566 | } 567 | 568 | @Override 569 | public void setRedirectUri(String s) { 570 | 571 | } 572 | 573 | @Override 574 | public RealmModel getRealm() { 575 | return null; 576 | } 577 | 578 | @Override 579 | public ClientModel getClient() { 580 | return null; 581 | } 582 | 583 | @Override 584 | public String getAction() { 585 | return null; 586 | } 587 | 588 | @Override 589 | public void setAction(String s) { 590 | 591 | } 592 | 593 | @Override 594 | public String getProtocol() { 595 | return null; 596 | } 597 | 598 | @Override 599 | public void setProtocol(String s) { 600 | 601 | } 602 | } 603 | ); 604 | var res = JsonHelper.stringify(user, WMPUserSessionModel.class); 605 | Assert.assertEquals("{\n" + 606 | " \"id\": \"test\",\n" + 607 | " \"realm\": \"\",\n" + 608 | " \"brokerSessionId\": null,\n" + 609 | " \"brokerUserId\": null,\n" + 610 | " \"lastSessionRefresh\": 0,\n" + 611 | " \"authMethod\": \"WMP\",\n" + 612 | " \"ipAddress\": \"0.0.0.0\",\n" + 613 | " \"user\": \"{\\n \\\"username\\\": \\\"test user\\\",\\n \\\"id\\\": \\\"hello\\\",\\n \\\"email\\\": null,\\n \\\"enabled\\\": false,\\n \\\"firstName\\\": null,\\n \\\"lastName\\\": null,\\n \\\"createdTimestamp\\\": null,\\n \\\"federationLink\\\": null,\\n \\\"serviceAccountClientLink\\\": null,\\n \\\"groupsCount\\\": 0,\\n \\\"attributes\\\": \\\"\\\"\\n}\",\n" + 614 | " \"loginUserName\": null,\n" + 615 | " \"started\": 0,\n" + 616 | " \"notes\": \"null\",\n" + 617 | " \"authenticatedClientSessions\": \"null\",\n" + 618 | " \"state\": \"null\"\n" + 619 | "}", res); 620 | } 621 | } 622 | --------------------------------------------------------------------------------