|
|
@@ -0,0 +1,422 @@
|
|
|
+/**
|
|
|
+ * Copyright © 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("统一认证未启用或配置不完整");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|