Browse Source

Merge remote-tracking branch 'origin/master'

徐滕 4 weeks atrás
parent
commit
aebceee103

+ 25 - 0
src/main/java/com/jeeplus/modules/sys/dao/SsoUserMappingDao.java

@@ -0,0 +1,25 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.dao;
+
+import com.jeeplus.common.persistence.annotation.MyBatisDao;
+import com.jeeplus.modules.sys.oidc.SsoUserMapping;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 统一身份与本地用户映射DAO。
+ */
+@MyBatisDao
+public interface SsoUserMappingDao {
+
+	String findUserId(@Param("issuer") String issuer, @Param("subject") String subject);
+
+	List<String> findActiveUserIdsByExactLoginName(@Param("loginName") String loginName);
+
+	List<String> findActiveUserIdsByExactName(@Param("name") String name);
+
+	int insertIgnore(SsoUserMapping mapping);
+}

+ 422 - 0
src/main/java/com/jeeplus/modules/sys/oidc/OidcClientService.java

@@ -0,0 +1,422 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.oidc;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jeeplus.common.utils.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.MessageDigest;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.spec.RSAPublicKeySpec;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Keycloak OIDC Authorization Code客户端。
+ *
+ * B系统技术栈较老,为避免升级Spring/Jackson引发大范围依赖冲突,这里仅使用JDK8、
+ * FastJSON和Keycloak标准RS256 JWKS完成协议处理。
+ */
+@Service
+public class OidcClientService {
+
+	private static final long CLOCK_SKEW_SECONDS = 60L;
+	private static final long JWKS_CACHE_MILLIS = 10L * 60L * 1000L;
+	private static final int CONNECT_TIMEOUT_MILLIS = 5000;
+	private static final int READ_TIMEOUT_MILLIS = 10000;
+
+	private final SecureRandom secureRandom = new SecureRandom();
+	private volatile Map<String, PublicKey> signingKeys = Collections.emptyMap();
+	private volatile long signingKeysExpiresAt;
+
+	@Autowired
+	private OidcConfig config;
+
+	public String newState() {
+		return randomUrlValue(32);
+	}
+
+	public String newNonce() {
+		return randomUrlValue(32);
+	}
+
+	public String newCodeVerifier() {
+		return randomUrlValue(48);
+	}
+
+	public String newAuthenticationProof() {
+		return randomUrlValue(32);
+	}
+
+	public String buildAuthorizationUrl(String state, String nonce, String codeVerifier, boolean passive) {
+		requireReady();
+		Map<String, String> parameters = new LinkedHashMap<String, String>();
+		parameters.put("client_id", config.getClientId());
+		parameters.put("redirect_uri", config.getRedirectUri());
+		parameters.put("response_type", "code");
+		parameters.put("response_mode", "query");
+		parameters.put("scope", "openid profile");
+		parameters.put("state", state);
+		parameters.put("nonce", nonce);
+		parameters.put("code_challenge", sha256UrlValue(codeVerifier));
+		parameters.put("code_challenge_method", "S256");
+		parameters.put("ui_locales", "zh-CN");
+		if (passive) {
+			parameters.put("prompt", "none");
+		}
+		return config.getAuthorizationEndpoint() + "?" + formEncode(parameters);
+	}
+
+	public OidcTokenResult exchangeAndVerify(String code, String codeVerifier, String expectedNonce) {
+		requireReady();
+		if (StringUtils.isBlank(code)
+				|| StringUtils.isBlank(codeVerifier)
+				|| StringUtils.isBlank(expectedNonce)) {
+			throw new OidcLoginException("统一认证回调参数不完整");
+		}
+
+		Map<String, String> parameters = new LinkedHashMap<String, String>();
+		parameters.put("grant_type", "authorization_code");
+		parameters.put("code", code);
+		parameters.put("redirect_uri", config.getRedirectUri());
+		parameters.put("client_id", config.getClientId());
+		parameters.put("client_secret", config.getClientSecret());
+		parameters.put("code_verifier", codeVerifier);
+
+		JSONObject tokenResponse = postForm(config.getTokenEndpoint(), parameters);
+		String idToken = tokenResponse.getString("id_token");
+		if (StringUtils.isBlank(idToken)) {
+			throw new OidcLoginException("Keycloak未返回ID Token");
+		}
+		return verifyIdToken(idToken, expectedNonce);
+	}
+
+	public String buildLogoutUrl(String idToken) {
+		requireReady();
+		Map<String, String> parameters = new LinkedHashMap<String, String>();
+		if (StringUtils.isNotBlank(idToken)) {
+			parameters.put("id_token_hint", idToken);
+		}
+		parameters.put("client_id", config.getClientId());
+		if (StringUtils.isNotBlank(config.getPostLogoutRedirectUri())) {
+			parameters.put("post_logout_redirect_uri", config.getPostLogoutRedirectUri());
+		}
+		return config.getLogoutEndpoint() + "?" + formEncode(parameters);
+	}
+
+	public boolean secureEquals(String expected, String actual) {
+		if (expected == null || actual == null) {
+			return false;
+		}
+		return MessageDigest.isEqual(
+				expected.getBytes(StandardCharsets.UTF_8),
+				actual.getBytes(StandardCharsets.UTF_8));
+	}
+
+	private OidcTokenResult verifyIdToken(String token, String expectedNonce) {
+		String[] parts = token.split("\\.");
+		if (parts.length != 3) {
+			throw new OidcLoginException("ID Token格式无效");
+		}
+
+		JSONObject header = parseJwtPart(parts[0], "ID Token头");
+		JSONObject claims = parseJwtPart(parts[1], "ID Token声明");
+		if (!"RS256".equals(header.getString("alg"))) {
+			throw new OidcLoginException("ID Token签名算法不受支持");
+		}
+		String keyId = header.getString("kid");
+		if (StringUtils.isBlank(keyId)) {
+			throw new OidcLoginException("ID Token缺少签名密钥标识");
+		}
+
+		try {
+			PublicKey publicKey = getSigningKey(keyId, false);
+			if (publicKey == null) {
+				publicKey = getSigningKey(keyId, true);
+			}
+			if (publicKey == null) {
+				throw new OidcLoginException("找不到ID Token对应的Keycloak签名密钥");
+			}
+			Signature verifier = Signature.getInstance("SHA256withRSA");
+			verifier.initVerify(publicKey);
+			verifier.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
+			if (!verifier.verify(Base64.getUrlDecoder().decode(parts[2]))) {
+				throw new OidcLoginException("ID Token签名验证失败");
+			}
+		} catch (OidcLoginException e) {
+			throw e;
+		} catch (Exception e) {
+			throw new OidcLoginException("ID Token签名验证失败", e);
+		}
+
+		validateClaims(claims, expectedNonce);
+		OidcTokenResult result = new OidcTokenResult();
+		result.setIssuer(claims.getString("iss"));
+		result.setSubject(claims.getString("sub"));
+		result.setPreferredUsername(claims.getString("preferred_username"));
+		result.setSessionId(claims.getString("sid"));
+		result.setIdToken(token);
+		return result;
+	}
+
+	private void validateClaims(JSONObject claims, String expectedNonce) {
+		String issuer = claims.getString("iss");
+		if (!secureEquals(config.getIssuer(), issuer)) {
+			throw new OidcLoginException("ID Token签发方不正确");
+		}
+		if (StringUtils.isBlank(claims.getString("sub"))) {
+			throw new OidcLoginException("ID Token缺少统一用户标识");
+		}
+		if (!containsAudience(claims.get("aud"), config.getClientId())) {
+			throw new OidcLoginException("ID Token受众不包含B系统客户端");
+		}
+
+		Object audience = claims.get("aud");
+		if (audience instanceof JSONArray && ((JSONArray) audience).size() > 1
+				&& !config.getClientId().equals(claims.getString("azp"))) {
+			throw new OidcLoginException("ID Token授权方不正确");
+		}
+
+		long now = System.currentTimeMillis() / 1000L;
+		Long expiresAt = claims.getLong("exp");
+		if (expiresAt == null || expiresAt.longValue() < now - CLOCK_SKEW_SECONDS) {
+			throw new OidcLoginException("ID Token已过期");
+		}
+		Long issuedAt = claims.getLong("iat");
+		if (issuedAt != null && issuedAt.longValue() > now + CLOCK_SKEW_SECONDS) {
+			throw new OidcLoginException("ID Token签发时间无效");
+		}
+		if (!secureEquals(expectedNonce, claims.getString("nonce"))) {
+			throw new OidcLoginException("ID Token nonce验证失败");
+		}
+	}
+
+	private boolean containsAudience(Object audience, String expectedAudience) {
+		if (audience instanceof String) {
+			return expectedAudience.equals(audience);
+		}
+		if (audience instanceof JSONArray) {
+			JSONArray values = (JSONArray) audience;
+			for (Object value : values) {
+				if (expectedAudience.equals(String.valueOf(value))) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	private JSONObject parseJwtPart(String value, String partName) {
+		try {
+			String json = new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8);
+			return JSON.parseObject(json);
+		} catch (Exception e) {
+			throw new OidcLoginException(partName + "格式无效", e);
+		}
+	}
+
+	private PublicKey getSigningKey(String keyId, boolean forceRefresh) {
+		if (forceRefresh
+				|| System.currentTimeMillis() >= signingKeysExpiresAt
+				|| !signingKeys.containsKey(keyId)) {
+			refreshSigningKeys(forceRefresh);
+		}
+		return signingKeys.get(keyId);
+	}
+
+	private synchronized void refreshSigningKeys(boolean forceRefresh) {
+		if (!forceRefresh
+				&& System.currentTimeMillis() < signingKeysExpiresAt
+				&& !signingKeys.isEmpty()) {
+			return;
+		}
+		JSONObject jwks = getJson(config.getJwksUri());
+		JSONArray keys = jwks.getJSONArray("keys");
+		if (keys == null || keys.isEmpty()) {
+			throw new OidcLoginException("Keycloak JWKS中没有签名密钥");
+		}
+
+		Map<String, PublicKey> loadedKeys = new HashMap<String, PublicKey>();
+		for (int i = 0; i < keys.size(); i++) {
+			JSONObject key = keys.getJSONObject(i);
+			if (!"RSA".equals(key.getString("kty"))
+					|| StringUtils.isBlank(key.getString("kid"))
+					|| StringUtils.isBlank(key.getString("n"))
+					|| StringUtils.isBlank(key.getString("e"))) {
+				continue;
+			}
+			try {
+				BigInteger modulus = new BigInteger(1,
+						Base64.getUrlDecoder().decode(key.getString("n")));
+				BigInteger exponent = new BigInteger(1,
+						Base64.getUrlDecoder().decode(key.getString("e")));
+				RSAPublicKeySpec keySpec = new RSAPublicKeySpec(modulus, exponent);
+				loadedKeys.put(key.getString("kid"),
+						KeyFactory.getInstance("RSA").generatePublic(keySpec));
+			} catch (Exception e) {
+				throw new OidcLoginException("解析Keycloak签名密钥失败", e);
+			}
+		}
+		if (loadedKeys.isEmpty()) {
+			throw new OidcLoginException("Keycloak JWKS中没有可用RSA密钥");
+		}
+		signingKeys = Collections.unmodifiableMap(loadedKeys);
+		signingKeysExpiresAt = System.currentTimeMillis() + JWKS_CACHE_MILLIS;
+	}
+
+	private JSONObject postForm(String endpoint, Map<String, String> parameters) {
+		byte[] requestBody = formEncode(parameters).getBytes(StandardCharsets.UTF_8);
+		HttpURLConnection connection = null;
+		try {
+			connection = openConnection(endpoint);
+			connection.setRequestMethod("POST");
+			connection.setDoOutput(true);
+			connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
+			connection.setRequestProperty("Accept", "application/json");
+			connection.setFixedLengthStreamingMode(requestBody.length);
+			OutputStream output = connection.getOutputStream();
+			try {
+				output.write(requestBody);
+			} finally {
+				output.close();
+			}
+			return readJsonResponse(connection);
+		} catch (OidcLoginException e) {
+			throw e;
+		} catch (Exception e) {
+			throw new OidcLoginException("连接Keycloak Token接口失败", e);
+		} finally {
+			if (connection != null) {
+				connection.disconnect();
+			}
+		}
+	}
+
+	private JSONObject getJson(String endpoint) {
+		HttpURLConnection connection = null;
+		try {
+			connection = openConnection(endpoint);
+			connection.setRequestMethod("GET");
+			connection.setRequestProperty("Accept", "application/json");
+			return readJsonResponse(connection);
+		} catch (OidcLoginException e) {
+			throw e;
+		} catch (Exception e) {
+			throw new OidcLoginException("读取Keycloak配置失败", e);
+		} finally {
+			if (connection != null) {
+				connection.disconnect();
+			}
+		}
+	}
+
+	private HttpURLConnection openConnection(String endpoint) throws IOException {
+		HttpURLConnection connection = (HttpURLConnection) new URL(endpoint).openConnection();
+		connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
+		connection.setReadTimeout(READ_TIMEOUT_MILLIS);
+		connection.setUseCaches(false);
+		return connection;
+	}
+
+	private JSONObject readJsonResponse(HttpURLConnection connection) throws IOException {
+		int status = connection.getResponseCode();
+		InputStream input = status >= 200 && status < 300
+				? connection.getInputStream()
+				: connection.getErrorStream();
+		String body = readAll(input);
+		JSONObject json;
+		try {
+			json = StringUtils.isBlank(body) ? new JSONObject() : JSON.parseObject(body);
+		} catch (Exception e) {
+			throw new OidcLoginException("Keycloak返回了无法解析的响应");
+		}
+		if (status < 200 || status >= 300) {
+			String description = json.getString("error_description");
+			if (StringUtils.isBlank(description)) {
+				description = json.getString("error");
+			}
+			throw new OidcLoginException("Keycloak请求失败"
+					+ (StringUtils.isBlank(description) ? "" : ":" + description));
+		}
+		return json;
+	}
+
+	private String readAll(InputStream input) throws IOException {
+		if (input == null) {
+			return "";
+		}
+		try {
+			ByteArrayOutputStream output = new ByteArrayOutputStream();
+			byte[] buffer = new byte[4096];
+			int count;
+			while ((count = input.read(buffer)) != -1) {
+				output.write(buffer, 0, count);
+			}
+			return new String(output.toByteArray(), StandardCharsets.UTF_8);
+		} finally {
+			input.close();
+		}
+	}
+
+	private String randomUrlValue(int byteLength) {
+		byte[] bytes = new byte[byteLength];
+		secureRandom.nextBytes(bytes);
+		return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+	}
+
+	private String sha256UrlValue(String value) {
+		try {
+			byte[] digest = MessageDigest.getInstance("SHA-256")
+					.digest(value.getBytes(StandardCharsets.US_ASCII));
+			return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+		} catch (Exception e) {
+			throw new OidcLoginException("无法生成OIDC PKCE参数", e);
+		}
+	}
+
+	private String formEncode(Map<String, String> values) {
+		StringBuilder result = new StringBuilder();
+		for (Map.Entry<String, String> entry : values.entrySet()) {
+			if (entry.getValue() == null) {
+				continue;
+			}
+			if (result.length() > 0) {
+				result.append('&');
+			}
+			try {
+				result.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
+				result.append('=');
+				result.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
+			} catch (Exception e) {
+				throw new OidcLoginException("OIDC参数编码失败", e);
+			}
+		}
+		return result.toString();
+	}
+
+	private void requireReady() {
+		if (!config.isReady()) {
+			throw new OidcLoginException("统一认证未启用或配置不完整");
+		}
+	}
+}

+ 87 - 0
src/main/java/com/jeeplus/modules/sys/oidc/OidcConfig.java

@@ -0,0 +1,87 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.oidc;
+
+import com.jeeplus.common.config.Global;
+import com.jeeplus.common.utils.StringUtils;
+import org.springframework.stereotype.Component;
+
+/**
+ * Keycloak OIDC配置。
+ *
+ * client secret优先从环境变量B_OIDC_CLIENT_SECRET读取,避免生产密钥进入代码仓库。
+ */
+@Component
+public class OidcConfig {
+
+	public boolean isEnabled() {
+		return Global.TRUE.equalsIgnoreCase(Global.getConfig("oidc.enabled"));
+	}
+
+	public boolean isAutoLogin() {
+		return Global.TRUE.equalsIgnoreCase(Global.getConfig("oidc.autoLogin"));
+	}
+
+	public boolean isAutoBindByLoginName() {
+		return Global.TRUE.equalsIgnoreCase(Global.getConfig("oidc.autoBindByLoginName"));
+	}
+
+	public boolean isReady() {
+		return isEnabled()
+				&& StringUtils.isNotBlank(getIssuer())
+				&& StringUtils.isNotBlank(getClientId())
+				&& StringUtils.isNotBlank(getClientSecret())
+				&& StringUtils.isNotBlank(getRedirectUri());
+	}
+
+	public String getIssuer() {
+		return trimTrailingSlash(Global.getConfig("oidc.issuer"));
+	}
+
+	public String getClientId() {
+		return Global.getConfig("oidc.clientId");
+	}
+
+	public String getClientSecret() {
+		String secret = System.getenv("B_OIDC_CLIENT_SECRET");
+		return StringUtils.isNotBlank(secret)
+				? secret
+				: Global.getConfig("oidc.clientSecret");
+	}
+
+	public String getRedirectUri() {
+		return Global.getConfig("oidc.redirectUri");
+	}
+
+	public String getPostLogoutRedirectUri() {
+		return Global.getConfig("oidc.postLogoutRedirectUri");
+	}
+
+	public String getAuthorizationEndpoint() {
+		return getIssuer() + "/protocol/openid-connect/auth";
+	}
+
+	public String getTokenEndpoint() {
+		return getIssuer() + "/protocol/openid-connect/token";
+	}
+
+	public String getJwksUri() {
+		return getIssuer() + "/protocol/openid-connect/certs";
+	}
+
+	public String getLogoutEndpoint() {
+		return getIssuer() + "/protocol/openid-connect/logout";
+	}
+
+	private String trimTrailingSlash(String value) {
+		if (value == null) {
+			return "";
+		}
+		String result = value.trim();
+		while (result.endsWith("/")) {
+			result = result.substring(0, result.length() - 1);
+		}
+		return result;
+	}
+}

+ 20 - 0
src/main/java/com/jeeplus/modules/sys/oidc/OidcLoginException.java

@@ -0,0 +1,20 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.oidc;
+
+/**
+ * 统一认证协议或用户映射异常。
+ */
+public class OidcLoginException extends RuntimeException {
+
+	private static final long serialVersionUID = 1L;
+
+	public OidcLoginException(String message) {
+		super(message);
+	}
+
+	public OidcLoginException(String message, Throwable cause) {
+		super(message, cause);
+	}
+}

+ 60 - 0
src/main/java/com/jeeplus/modules/sys/oidc/OidcTokenResult.java

@@ -0,0 +1,60 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.oidc;
+
+import java.io.Serializable;
+
+/**
+ * 已完成签名和声明校验的OIDC Token结果。
+ */
+public class OidcTokenResult implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private String issuer;
+	private String subject;
+	private String preferredUsername;
+	private String sessionId;
+	private String idToken;
+
+	public String getIssuer() {
+		return issuer;
+	}
+
+	public void setIssuer(String issuer) {
+		this.issuer = issuer;
+	}
+
+	public String getSubject() {
+		return subject;
+	}
+
+	public void setSubject(String subject) {
+		this.subject = subject;
+	}
+
+	public String getPreferredUsername() {
+		return preferredUsername;
+	}
+
+	public void setPreferredUsername(String preferredUsername) {
+		this.preferredUsername = preferredUsername;
+	}
+
+	public String getSessionId() {
+		return sessionId;
+	}
+
+	public void setSessionId(String sessionId) {
+		this.sessionId = sessionId;
+	}
+
+	public String getIdToken() {
+		return idToken;
+	}
+
+	public void setIdToken(String idToken) {
+		this.idToken = idToken;
+	}
+}

+ 93 - 0
src/main/java/com/jeeplus/modules/sys/oidc/OidcUserMappingService.java

@@ -0,0 +1,93 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.oidc;
+
+import com.jeeplus.common.utils.IdGen;
+import com.jeeplus.common.utils.StringUtils;
+import com.jeeplus.modules.sys.dao.SsoUserMappingDao;
+import com.jeeplus.modules.sys.dao.UserDao;
+import com.jeeplus.modules.sys.entity.User;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 将Keycloak不可变subject解析为B系统用户。
+ */
+@Service
+@Transactional(readOnly = true)
+public class OidcUserMappingService {
+
+	@Autowired
+	private SsoUserMappingDao mappingDao;
+
+	@Autowired
+	private UserDao userDao;
+
+	@Autowired
+	private OidcConfig config;
+
+	@Transactional(readOnly = false)
+	public User resolveUser(OidcTokenResult token) {
+		if (token == null
+				|| StringUtils.isBlank(token.getIssuer())
+				|| StringUtils.isBlank(token.getSubject())) {
+			throw new OidcLoginException("统一认证用户标识不完整");
+		}
+
+		String userId = mappingDao.findUserId(token.getIssuer(), token.getSubject());
+		if (StringUtils.isBlank(userId)) {
+			userId = autoBind(token);
+		}
+
+		User user = userDao.getByUserId(userId);
+		if (user == null || StringUtils.isBlank(user.getId())) {
+			throw new OidcLoginException("此系统用户不存在或已停用");
+		}
+		return user;
+	}
+
+	private String autoBind(OidcTokenResult token) {
+		if (!config.isAutoBindByLoginName()) {
+			throw new OidcLoginException("统一认证账号尚未绑定此系统用户");
+		}
+		if (StringUtils.isBlank(token.getPreferredUsername())) {
+			throw new OidcLoginException("统一认证账号缺少用户名,无法自动绑定此系统用户");
+		}
+
+		List<String> candidates = mappingDao.findActiveUserIdsByExactLoginName(
+				token.getPreferredUsername());
+		if (candidates == null || candidates.isEmpty()) {
+			candidates = mappingDao.findActiveUserIdsByExactName(
+					token.getPreferredUsername());
+		}
+		if (candidates == null || candidates.isEmpty()) {
+			throw new OidcLoginException("此系统不存在当前登录的用户");
+		}
+		if (candidates.size() != 1) {
+			throw new OidcLoginException("此系统存在多个同名用户,请先建立明确的统一身份映射");
+		}
+
+		Date now = new Date();
+		SsoUserMapping mapping = new SsoUserMapping();
+		mapping.setId(IdGen.uuid());
+		mapping.setIssuer(token.getIssuer());
+		mapping.setSubject(token.getSubject());
+		mapping.setUserId(candidates.get(0));
+		mapping.setIdentityUsername(token.getPreferredUsername());
+		mapping.setEnabled("1");
+		mapping.setCreateDate(now);
+		mapping.setUpdateDate(now);
+		mappingDao.insertIgnore(mapping);
+
+		String mappedUserId = mappingDao.findUserId(token.getIssuer(), token.getSubject());
+		if (StringUtils.isBlank(mappedUserId)) {
+			throw new OidcLoginException("统一认证账号已绑定其他此系统用户,请检查映射关系");
+		}
+		return mappedUserId;
+	}
+}

+ 88 - 0
src/main/java/com/jeeplus/modules/sys/oidc/SsoUserMapping.java

@@ -0,0 +1,88 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.oidc;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * Keycloak统一身份与B系统用户的稳定映射。
+ */
+public class SsoUserMapping implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	private String id;
+	private String issuer;
+	private String subject;
+	private String userId;
+	private String identityUsername;
+	private String enabled;
+	private Date createDate;
+	private Date updateDate;
+
+	public String getId() {
+		return id;
+	}
+
+	public void setId(String id) {
+		this.id = id;
+	}
+
+	public String getIssuer() {
+		return issuer;
+	}
+
+	public void setIssuer(String issuer) {
+		this.issuer = issuer;
+	}
+
+	public String getSubject() {
+		return subject;
+	}
+
+	public void setSubject(String subject) {
+		this.subject = subject;
+	}
+
+	public String getUserId() {
+		return userId;
+	}
+
+	public void setUserId(String userId) {
+		this.userId = userId;
+	}
+
+	public String getIdentityUsername() {
+		return identityUsername;
+	}
+
+	public void setIdentityUsername(String identityUsername) {
+		this.identityUsername = identityUsername;
+	}
+
+	public String getEnabled() {
+		return enabled;
+	}
+
+	public void setEnabled(String enabled) {
+		this.enabled = enabled;
+	}
+
+	public Date getCreateDate() {
+		return createDate;
+	}
+
+	public void setCreateDate(Date createDate) {
+		this.createDate = createDate;
+	}
+
+	public Date getUpdateDate() {
+		return updateDate;
+	}
+
+	public void setUpdateDate(Date updateDate) {
+		this.updateDate = updateDate;
+	}
+}

+ 42 - 0
src/main/java/com/jeeplus/modules/sys/security/HybridCredentialsMatcher.java

@@ -0,0 +1,42 @@
+package com.jeeplus.modules.sys.security;
+
+import org.apache.shiro.authc.AuthenticationInfo;
+import org.apache.shiro.authc.AuthenticationToken;
+import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/**
+ * 原密码登录继续使用散列校验;OIDC预认证使用一次性proof精确校验。
+ */
+public class HybridCredentialsMatcher extends HashedCredentialsMatcher {
+
+	public HybridCredentialsMatcher(String hashAlgorithmName) {
+		super(hashAlgorithmName);
+	}
+
+	@Override
+	public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
+		if (!(token instanceof OidcAuthenticationToken)) {
+			return super.doCredentialsMatch(token, info);
+		}
+		byte[] tokenCredentials = credentialsToBytes(token.getCredentials());
+		byte[] storedCredentials = credentialsToBytes(info.getCredentials());
+		return tokenCredentials != null
+				&& storedCredentials != null
+				&& MessageDigest.isEqual(tokenCredentials, storedCredentials);
+	}
+
+	private byte[] credentialsToBytes(Object credentials) {
+		if (credentials instanceof char[]) {
+			return new String((char[]) credentials).getBytes(StandardCharsets.UTF_8);
+		}
+		if (credentials instanceof byte[]) {
+			return (byte[]) credentials;
+		}
+		return credentials == null
+				? null
+				: String.valueOf(credentials).getBytes(StandardCharsets.UTF_8);
+	}
+}

+ 25 - 0
src/main/java/com/jeeplus/modules/sys/security/OidcAuthenticationToken.java

@@ -0,0 +1,25 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.security;
+
+/**
+ * ID Token验证通过后用于建立B系统Shiro会话的预认证令牌。
+ *
+ * proof是每次回调随机生成的短期凭据,不是Keycloak密码或Token。
+ */
+public class OidcAuthenticationToken extends UsernamePasswordToken {
+
+	private static final long serialVersionUID = 1L;
+
+	private final String userId;
+
+	public OidcAuthenticationToken(String userId, String loginName, String proof, String host) {
+		super(loginName, proof.toCharArray(), false, host, null, false);
+		this.userId = userId;
+	}
+
+	public String getUserId() {
+		return userId;
+	}
+}

+ 31 - 20
src/main/java/com/jeeplus/modules/sys/security/SystemAuthorizingRealm.java

@@ -22,7 +22,6 @@ import org.apache.shiro.authc.AuthenticationException;
 import org.apache.shiro.authc.AuthenticationInfo;
 import org.apache.shiro.authc.AuthenticationInfo;
 import org.apache.shiro.authc.AuthenticationToken;
 import org.apache.shiro.authc.AuthenticationToken;
 import org.apache.shiro.authc.SimpleAuthenticationInfo;
 import org.apache.shiro.authc.SimpleAuthenticationInfo;
-import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
 import org.apache.shiro.authz.AuthorizationInfo;
 import org.apache.shiro.authz.AuthorizationInfo;
 import org.apache.shiro.authz.Permission;
 import org.apache.shiro.authz.Permission;
 import org.apache.shiro.authz.SimpleAuthorizationInfo;
 import org.apache.shiro.authz.SimpleAuthorizationInfo;
@@ -76,6 +75,8 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 //		Jedis jedis = null;
 //		Jedis jedis = null;
 		/*try {*/
 		/*try {*/
 			UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
 			UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
+			boolean oidcLogin = token instanceof OidcAuthenticationToken;
+			SystemService authenticationSystemService = getSystemService();
 			/*int activeSessionSize = getSystemService().getSessionDao().getActiveSessions(false).size();
 			/*int activeSessionSize = getSystemService().getSessionDao().getActiveSessions(false).size();
 			if (logger.isDebugEnabled()){
 			if (logger.isDebugEnabled()){
 				logger.debug("login submit, active session size: {}, username: {}", activeSessionSize, token.getUsername());
 				logger.debug("login submit, active session size: {}, username: {}", activeSessionSize, token.getUsername());
@@ -97,18 +98,22 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 			}*/
 			}*/
 
 
 			// 校验用户名密码
 			// 校验用户名密码
-			User user = getSystemService().getUserByLoginName(token.getUsername());
+			User user = oidcLogin
+					? userDao.getByUserId(((OidcAuthenticationToken) token).getUserId())
+					: authenticationSystemService.getUserByLoginName(token.getUsername());
 			if (user ==null || StringUtils.isBlank(user.getId())){
 			if (user ==null || StringUtils.isBlank(user.getId())){
 				throw new AuthenticationException("msg:登录失败, 该用户未注册.");
 				throw new AuthenticationException("msg:登录失败, 该用户未注册.");
 			}
 			}
 			//多用户校验
 			//多用户校验
-		    User user1 = new User();
-			String loginName = token.getUsername();
-		    user1.setName(loginName);
-		    List<User> list = userDao.getByName(user1);
-		    if(list != null && list.size()>1){
-			    throw new AuthenticationException("msg:"+loginName+"存在重名.");
-		    }
+			if (!oidcLogin) {
+				User user1 = new User();
+				String loginName = token.getUsername();
+				user1.setName(loginName);
+				List<User> list = userDao.getByName(user1);
+				if(list != null && list.size()>1){
+					throw new AuthenticationException("msg:"+loginName+"存在重名.");
+				}
+			}
 			if (user !=null && user.getCompany()!=null && StringUtils.isNotBlank(user.getCompany().getUseable()) && "2".equals(user.getCompany().getUseable())){
 			if (user !=null && user.getCompany()!=null && StringUtils.isNotBlank(user.getCompany().getUseable()) && "2".equals(user.getCompany().getUseable())){
 				throw new AuthenticationException("msg:登录失败, 该用户当前所在公司已被禁用.");
 				throw new AuthenticationException("msg:登录失败, 该用户当前所在公司已被禁用.");
 			}
 			}
@@ -118,19 +123,17 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 				if (Global.NO.equals(user.getLoginFlag())){
 				if (Global.NO.equals(user.getLoginFlag())){
 					throw new AuthenticationException("msg:该帐号已注销.");
 					throw new AuthenticationException("msg:该帐号已注销.");
 				}
 				}
-				byte[] salt = Encodes.decodeHex(user.getPassword().substring(0,16));
-
 				//****将选择的企业加入Cache**start********//
 				//****将选择的企业加入Cache**start********//
 
 
 				Role role1 = new Role();
 				Role role1 = new Role();
 				role1.setUser(user);
 				role1.setUser(user);
-				List<Role> roleList=systemService.findRole(role1);
+				List<Role> roleList=authenticationSystemService.findRole(role1);
 				if(roleList==null||roleList.size()==0){
 				if(roleList==null||roleList.size()==0){
                     List<Dict> dictList = DictUtils.getDictList("defalut_role");
                     List<Dict> dictList = DictUtils.getDictList("defalut_role");
                     Dict dict = dictList.get(0);
                     Dict dict = dictList.get(0);
-                    Role role = systemService.getRole(dict.getValue());
+                    Role role = authenticationSystemService.getRole(dict.getValue());
                     role.setOffice(new Office("5"));
                     role.setOffice(new Office("5"));
-                    systemService.assignUserToRole(role,user,0);
+                    authenticationSystemService.assignUserToRole(role,user,0);
                     roleList.add(role);
                     roleList.add(role);
                 }
                 }
 				String companyName = request.getParameter("companyName");
 				String companyName = request.getParameter("companyName");
@@ -163,8 +166,7 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 
 
 					}
 					}
 					UserUtils.putCache("selectRole",roles);
 					UserUtils.putCache("selectRole",roles);
-					return new SimpleAuthenticationInfo(new Principal(user, token.isMobileLogin()),
-							user.getPassword().substring(16), ByteSource.Util.bytes(salt), getName());
+					return createAuthenticationInfo(user, token);
 				}
 				}
 				if(companyName.equals("新用户")){
 				if(companyName.equals("新用户")){
 					companyName="总公司";
 					companyName="总公司";
@@ -259,8 +261,7 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 				}
 				}
 				UserUtils.putCache("selectRole",roles);
 				UserUtils.putCache("selectRole",roles);
 				//****将选择的企业加入Cache**end********//
 				//****将选择的企业加入Cache**end********//
-				Principal p =new Principal(user, token.isMobileLogin());
-				return new SimpleAuthenticationInfo(p,user.getPassword().substring(16), ByteSource.Util.bytes(salt), getName());
+				return createAuthenticationInfo(user, token);
 			} else {
 			} else {
 				return null;
 				return null;
 			}
 			}
@@ -272,6 +273,16 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 		}*/
 		}*/
 	}
 	}
 
 
+	private AuthenticationInfo createAuthenticationInfo(User user, UsernamePasswordToken token) {
+		Principal principal = new Principal(user, token.isMobileLogin());
+		if (token instanceof OidcAuthenticationToken) {
+			return new SimpleAuthenticationInfo(principal, token.getCredentials(), getName());
+		}
+		byte[] salt = Encodes.decodeHex(user.getPassword().substring(0, 16));
+		return new SimpleAuthenticationInfo(principal, user.getPassword().substring(16),
+				ByteSource.Util.bytes(salt), getName());
+	}
+
 	/**
 	/**
 	 * 授权查询回调函数, 进行鉴权但缓存中无用户的授权信息时调用
 	 * 授权查询回调函数, 进行鉴权但缓存中无用户的授权信息时调用
 	 */
 	 */
@@ -295,7 +306,7 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 				}
 				}
 			}
 			}
 		}
 		}
-		User user = getSystemService().getUserByLoginName(principal.getLoginName());
+		User user = userDao.getByUserId(principal.getId());
 		if (user != null) {
 		if (user != null) {
 			SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
 			SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
 			List<Menu> list = UserUtils.getMenuList();
 			List<Menu> list = UserUtils.getMenuList();
@@ -368,7 +379,7 @@ public class SystemAuthorizingRealm extends AuthorizingRealm {
 	 */
 	 */
 	@PostConstruct
 	@PostConstruct
 	public void initCredentialsMatcher() {
 	public void initCredentialsMatcher() {
-		HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(SystemService.HASH_ALGORITHM);
+		HybridCredentialsMatcher matcher = new HybridCredentialsMatcher(SystemService.HASH_ALGORITHM);
 		matcher.setHashIterations(SystemService.HASH_INTERATIONS);
 		matcher.setHashIterations(SystemService.HASH_INTERATIONS);
 		setCredentialsMatcher(matcher);
 		setCredentialsMatcher(matcher);
 	}
 	}

+ 25 - 0
src/main/java/com/jeeplus/modules/sys/web/LoginController.java

@@ -32,6 +32,8 @@ import com.jeeplus.modules.sys.dao.UserDao;
 import com.jeeplus.modules.sys.entity.MainDictDetail;
 import com.jeeplus.modules.sys.entity.MainDictDetail;
 import com.jeeplus.modules.sys.entity.Office;
 import com.jeeplus.modules.sys.entity.Office;
 import com.jeeplus.modules.sys.entity.User;
 import com.jeeplus.modules.sys.entity.User;
+import com.jeeplus.modules.sys.oidc.OidcClientService;
+import com.jeeplus.modules.sys.oidc.OidcConfig;
 import com.jeeplus.modules.sys.security.FormAuthenticationFilter;
 import com.jeeplus.modules.sys.security.FormAuthenticationFilter;
 import com.jeeplus.modules.sys.security.SystemAuthorizingRealm.Principal;
 import com.jeeplus.modules.sys.security.SystemAuthorizingRealm.Principal;
 import com.jeeplus.modules.sys.service.OfficeService;
 import com.jeeplus.modules.sys.service.OfficeService;
@@ -118,6 +120,10 @@ public class LoginController extends BaseController{
 	private SzFlowRequest szFlowRequest;
 	private SzFlowRequest szFlowRequest;
 	@Autowired
 	@Autowired
 	private WorkOssNoteInformInfoService workOssNoteInformInfoService;
 	private WorkOssNoteInformInfoService workOssNoteInformInfoService;
+	@Autowired
+	private OidcConfig oidcConfig;
+	@Autowired
+	private OidcClientService oidcClientService;
 
 
 	/**
 	/**
 	 * 管理登录
 	 * 管理登录
@@ -161,11 +167,18 @@ public class LoginController extends BaseController{
 				return renderString(response, j);
 				return renderString(response, j);
 			}
 			}
 		}
 		}
+		if (oidcConfig.isReady()
+				&& oidcConfig.isAutoLogin()
+				&& !"1".equals(request.getParameter("oidcChecked"))
+				&& !"true".equalsIgnoreCase(request.getParameter("local"))) {
+			return "redirect:" + adminPath + "/oidc/login?passive=true";
+		}
 		//记住我(用户信息)
 		//记住我(用户信息)
 		String username=CookieUtils.getCookie(request,"username");
 		String username=CookieUtils.getCookie(request,"username");
 		String password=CookieUtils.getCookie(request,"password");
 		String password=CookieUtils.getCookie(request,"password");
 		model.addAttribute("username", username);
 		model.addAttribute("username", username);
 		model.addAttribute("password", password);
 		model.addAttribute("password", password);
+		model.addAttribute("oidcEnabled", oidcConfig.isReady());
 
 
 		UserUtils.saveSelectCompany();
 		UserUtils.saveSelectCompany();
 		return "modules/sys/sysLogin";
 		return "modules/sys/sysLogin";
@@ -180,6 +193,7 @@ public class LoginController extends BaseController{
 	public String loginFail(HttpServletRequest request, HttpServletResponse response, Model model) {
 	public String loginFail(HttpServletRequest request, HttpServletResponse response, Model model) {
 		Jedis jedis = null;
 		Jedis jedis = null;
 		try {
 		try {
+			model.addAttribute("oidcEnabled", oidcConfig.isReady());
 			Principal principal = UserUtils.getPrincipal();
 			Principal principal = UserUtils.getPrincipal();
 
 
 			// 如果已经登录,则跳转到管理首页
 			// 如果已经登录,则跳转到管理首页
@@ -295,6 +309,13 @@ public class LoginController extends BaseController{
 	@RequestMapping(value = "${adminPath}/logout", method = RequestMethod.GET)
 	@RequestMapping(value = "${adminPath}/logout", method = RequestMethod.GET)
 	public String logout(HttpServletRequest request, HttpServletResponse response, Model model) throws IOException {
 	public String logout(HttpServletRequest request, HttpServletResponse response, Model model) throws IOException {
 		Principal principal = UserUtils.getPrincipal();
 		Principal principal = UserUtils.getPrincipal();
+		String authenticationSource = null;
+		String idToken = null;
+		org.apache.shiro.session.Session session = UserUtils.getSubject().getSession(false);
+		if (session != null) {
+			authenticationSource = (String) session.getAttribute(OidcLoginController.AUTH_SOURCE);
+			idToken = (String) session.getAttribute(OidcLoginController.ID_TOKEN);
+		}
 		// 如果已经登录,则跳转到管理首页
 		// 如果已经登录,则跳转到管理首页
 		if(principal != null){
 		if(principal != null){
 			UserUtils.getSubject().logout();
 			UserUtils.getSubject().logout();
@@ -307,6 +328,10 @@ public class LoginController extends BaseController{
 			model.addAttribute("msg", "退出成功");
 			model.addAttribute("msg", "退出成功");
 			return renderString(response, model);
 			return renderString(response, model);
 		}
 		}
+		if (OidcLoginController.AUTH_SOURCE_OIDC.equals(authenticationSource)
+				&& oidcConfig.isReady()) {
+			return "redirect:" + oidcClientService.buildLogoutUrl(idToken);
+		}
 		return "redirect:" + adminPath+"/login";
 		return "redirect:" + adminPath+"/login";
 	}
 	}
 
 

+ 144 - 0
src/main/java/com/jeeplus/modules/sys/web/OidcLoginController.java

@@ -0,0 +1,144 @@
+/**
+ * Copyright &copy; 2013-2017 <a href="http://www.rhcncpa.com/">瑞华会计师事务所</a> All rights reserved.
+ */
+package com.jeeplus.modules.sys.web;
+
+import com.jeeplus.common.utils.StringUtils;
+import com.jeeplus.common.web.BaseController;
+import com.jeeplus.modules.sys.entity.User;
+import com.jeeplus.modules.sys.oidc.OidcClientService;
+import com.jeeplus.modules.sys.oidc.OidcConfig;
+import com.jeeplus.modules.sys.oidc.OidcLoginException;
+import com.jeeplus.modules.sys.oidc.OidcTokenResult;
+import com.jeeplus.modules.sys.oidc.OidcUserMappingService;
+import com.jeeplus.modules.sys.security.OidcAuthenticationToken;
+import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.authc.AuthenticationException;
+import org.apache.shiro.session.Session;
+import org.apache.shiro.subject.Subject;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * B系统服务端OIDC登录入口和回调。
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/oidc")
+public class OidcLoginController extends BaseController {
+
+	public static final String AUTH_SOURCE = "OIDC_AUTH_SOURCE";
+	public static final String AUTH_SOURCE_OIDC = "oidc";
+	public static final String ID_TOKEN = "OIDC_ID_TOKEN";
+	public static final String SESSION_ID = "OIDC_SESSION_ID";
+	public static final String SUBJECT = "OIDC_SUBJECT";
+
+	private static final String LOGIN_STATE = "OIDC_LOGIN_STATE";
+	private static final String LOGIN_NONCE = "OIDC_LOGIN_NONCE";
+	private static final String LOGIN_CODE_VERIFIER = "OIDC_LOGIN_CODE_VERIFIER";
+	private static final String LOGIN_PASSIVE = "OIDC_LOGIN_PASSIVE";
+
+	@Autowired
+	private OidcConfig config;
+
+	@Autowired
+	private OidcClientService clientService;
+
+	@Autowired
+	private OidcUserMappingService mappingService;
+
+	@RequestMapping(value = "login")
+	public String login(HttpServletRequest request, RedirectAttributes redirectAttributes) {
+		if (!config.isReady()) {
+			redirectAttributes.addFlashAttribute("message", "统一认证未启用或配置不完整");
+			return "redirect:" + adminPath + "/login?oidcChecked=1";
+		}
+
+		boolean passive = "true".equalsIgnoreCase(request.getParameter("passive"));
+		String state = clientService.newState();
+		String nonce = clientService.newNonce();
+		String codeVerifier = clientService.newCodeVerifier();
+
+		Session session = SecurityUtils.getSubject().getSession();
+		session.setAttribute(LOGIN_STATE, state);
+		session.setAttribute(LOGIN_NONCE, nonce);
+		session.setAttribute(LOGIN_CODE_VERIFIER, codeVerifier);
+		session.setAttribute(LOGIN_PASSIVE, passive);
+
+		return "redirect:" + clientService.buildAuthorizationUrl(
+				state, nonce, codeVerifier, passive);
+	}
+
+	@RequestMapping(value = "callback")
+	public String callback(HttpServletRequest request, RedirectAttributes redirectAttributes) {
+		Session session = SecurityUtils.getSubject().getSession();
+		String expectedState = asString(session.getAttribute(LOGIN_STATE));
+		String expectedNonce = asString(session.getAttribute(LOGIN_NONCE));
+		String codeVerifier = asString(session.getAttribute(LOGIN_CODE_VERIFIER));
+		boolean passive = Boolean.TRUE.equals(session.getAttribute(LOGIN_PASSIVE));
+		clearLoginAttempt(session);
+
+		String actualState = request.getParameter("state");
+		if (StringUtils.isBlank(expectedState)
+				|| !clientService.secureEquals(expectedState, actualState)) {
+			redirectAttributes.addFlashAttribute("message", "统一认证回调state校验失败,请重新登录");
+			return "redirect:" + adminPath + "/login?oidcChecked=1";
+		}
+
+		String error = request.getParameter("error");
+		if (StringUtils.isNotBlank(error)) {
+			if (!passive) {
+				redirectAttributes.addFlashAttribute("message", "统一认证登录未完成,请重试");
+			}
+			return "redirect:" + adminPath + "/login?oidcChecked=1";
+		}
+
+		try {
+			OidcTokenResult token = clientService.exchangeAndVerify(
+					request.getParameter("code"), codeVerifier, expectedNonce);
+			User user = mappingService.resolveUser(token);
+			String proof = clientService.newAuthenticationProof();
+
+			Subject subject = SecurityUtils.getSubject();
+			subject.login(new OidcAuthenticationToken(
+					user.getId(), user.getLoginName(), proof,
+					StringUtils.getRemoteAddr(request)));
+
+			Session authenticatedSession = subject.getSession();
+			authenticatedSession.setAttribute(AUTH_SOURCE, AUTH_SOURCE_OIDC);
+			authenticatedSession.setAttribute(ID_TOKEN, token.getIdToken());
+			authenticatedSession.setAttribute(SESSION_ID, token.getSessionId());
+			authenticatedSession.setAttribute(SUBJECT, token.getSubject());
+			return "redirect:" + adminPath;
+		} catch (OidcLoginException e) {
+			logger.warn("统一认证登录失败: {}", e.getMessage());
+			String displayMessage = e.getMessage();
+			if (StringUtils.isBlank(displayMessage)
+					|| displayMessage.startsWith("Keycloak请求失败")) {
+				displayMessage = "统一认证服务拒绝了登录请求,请重试或联系管理员";
+			}
+			redirectAttributes.addFlashAttribute("message", displayMessage);
+		} catch (AuthenticationException e) {
+			logger.warn("统一认证建立B系统会话失败: {}", e.getMessage());
+			redirectAttributes.addFlashAttribute("message", "统一认证账号无权登录此系统");
+		} catch (Exception e) {
+			logger.error("统一认证登录发生未知异常", e);
+			redirectAttributes.addFlashAttribute("message", "统一认证登录失败,请联系管理员");
+		}
+		return "redirect:" + adminPath + "/login?oidcChecked=1";
+	}
+
+	private void clearLoginAttempt(Session session) {
+		session.removeAttribute(LOGIN_STATE);
+		session.removeAttribute(LOGIN_NONCE);
+		session.removeAttribute(LOGIN_CODE_VERIFIER);
+		session.removeAttribute(LOGIN_PASSIVE);
+	}
+
+	private String asString(Object value) {
+		return value == null ? null : String.valueOf(value);
+	}
+}

+ 14 - 1
src/main/resources/jeeplus.properties

@@ -431,4 +431,17 @@ omsBankAccount: 7329010182600006811
 omsUrl: https://oms-sandbox.einvoice.js.cn:7079
 omsUrl: https://oms-sandbox.einvoice.js.cn:7079
 #omsUrl: https://www.oms.ejinshui-cloud.com:8899
 #omsUrl: https://www.oms.ejinshui-cloud.com:8899
 #\u7528\u4E8E\u5224\u5B9A\u662F\u5426\u5F00\u542Foms\u5F00\u7968\u6D41\u7A0B\u4E8B\u4EF6
 #\u7528\u4E8E\u5224\u5B9A\u662F\u5426\u5F00\u542Foms\u5F00\u7968\u6D41\u7A0B\u4E8B\u4EF6
-omsEnabled: false
+omsEnabled: false
+
+oidc.enabled=false
+oidc.autoLogin=true
+oidc.autoBindByLoginName=true
+#????
+oidc.issuer=http://127.0.0.1:8180/realms/xg-cloud
+#?????
+oidc.clientId=simple-xg-web
+#?????
+oidc.clientSecret=KCOPjVcNFIEyUdxalBHNWuIpdBDQY5X8Eg0V36FPt3J2VzIapPqJo5HdjJCI3YPHGh9xTqSTJ2Ndtdr0Se63jj
+#????
+oidc.redirectUri=http://127.0.0.1:8080/a/oidc/callback
+oidc.postLogoutRedirectUri=http://127.0.0.1:8080/a/login?oidcChecked=1

+ 46 - 0
src/main/resources/mappings/modules/sys/SsoUserMappingDao.xml

@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+		"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.jeeplus.modules.sys.dao.SsoUserMappingDao">
+
+	<select id="findUserId" resultType="java.lang.String">
+		SELECT m.user_id
+		FROM sys_sso_user_mapping m
+		INNER JOIN sys_user u ON u.id = m.user_id
+		WHERE m.issuer = #{issuer}
+		  AND m.subject = #{subject}
+		  AND m.enabled = '1'
+		  AND u.del_flag = '0'
+		  AND u.login_flag = '1'
+		LIMIT 1
+	</select>
+
+	<select id="findActiveUserIdsByExactLoginName" resultType="java.lang.String">
+		SELECT u.id
+		FROM sys_user u
+		WHERE u.login_name = #{loginName}
+		  AND u.del_flag = '0'
+		  AND u.login_flag = '1'
+		ORDER BY u.id
+		LIMIT 2
+	</select>
+
+	<select id="findActiveUserIdsByExactName" resultType="java.lang.String">
+		SELECT u.id
+		FROM sys_user u
+		WHERE u.name = #{name}
+		  AND u.del_flag = '0'
+		  AND u.login_flag = '1'
+		ORDER BY u.id
+		LIMIT 2
+	</select>
+
+	<insert id="insertIgnore" parameterType="com.jeeplus.modules.sys.oidc.SsoUserMapping">
+		INSERT IGNORE INTO sys_sso_user_mapping
+			(id, issuer, subject, user_id, identity_username, enabled, create_date, update_date)
+		VALUES
+			(#{id}, #{issuer}, #{subject}, #{userId}, #{identityUsername}, #{enabled},
+			 #{createDate}, #{updateDate})
+	</insert>
+
+</mapper>

+ 1 - 0
src/main/resources/spring-context-shiro.xml

@@ -56,6 +56,7 @@
                 ${adminPath}/soft/sysVersion/getAndroidVer = anon
                 ${adminPath}/soft/sysVersion/getAndroidVer = anon
                 ${adminPath}/soft/sysVersion/getIosVer = anon
                 ${adminPath}/soft/sysVersion/getIosVer = anon
                 ${adminPath}/projectreportnum/projectReportNum/report = anon
                 ${adminPath}/projectreportnum/projectReportNum/report = anon
+                ${adminPath}/oidc/** = anon
                 ${adminPath}/cas = cas
                 ${adminPath}/cas = cas
                 ${adminPath}/login = authc
                 ${adminPath}/login = authc
                 ${adminPath}/logout = anon
                 ${adminPath}/logout = anon

+ 10 - 4
src/main/webapp/webpage/modules/sys/sysLogin.jsp

@@ -717,10 +717,16 @@
 												</a>
 												</a>
 											</div>
 											</div>
 
 
-
-
-
-
+											<c:if test="${oidcEnabled}">
+												<div class="clearfix" style="margin-top:30px;">
+													<a href="${ctx}/oidc/login"
+													   class="btn btn-sm btn-info"
+													   style="width:100%;background: #428bca !important;border-color:#428bca !important">
+														<i class="ace-icon fa fa-sign-in"></i>
+														<span class="bigger-110">统一认证登录</span>
+													</a>
+												</div>
+											</c:if>