|
|
@@ -0,0 +1,188 @@
|
|
|
+package com.ruoyi.app.utils.oauth;
|
|
|
+
|
|
|
+import com.alibaba.fastjson2.JSONObject;
|
|
|
+import com.nimbusds.jose.JWSVerifier;
|
|
|
+import com.nimbusds.jose.crypto.ECDSAVerifier;
|
|
|
+import com.nimbusds.jose.crypto.RSASSAVerifier;
|
|
|
+import com.nimbusds.jose.jwk.ECKey;
|
|
|
+import com.nimbusds.jose.jwk.JWK;
|
|
|
+import com.nimbusds.jose.jwk.JWKSet;
|
|
|
+import com.nimbusds.jose.jwk.RSAKey;
|
|
|
+import com.nimbusds.jwt.JWTClaimsSet;
|
|
|
+import com.nimbusds.jwt.SignedJWT;
|
|
|
+import com.ruoyi.common.exception.ServiceException;
|
|
|
+import com.ruoyi.common.utils.MessageUtils;
|
|
|
+import org.apache.http.client.config.RequestConfig;
|
|
|
+import org.apache.http.client.methods.CloseableHttpResponse;
|
|
|
+import org.apache.http.client.methods.HttpGet;
|
|
|
+import org.apache.http.impl.client.CloseableHttpClient;
|
|
|
+import org.apache.http.impl.client.HttpClients;
|
|
|
+import org.apache.http.util.EntityUtils;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.net.URL;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 三方登录凭证校验(017-oauth-login)。
|
|
|
+ * <p>Apple/Google/LINE 各自校验前端传来的凭证,返回稳定的 providerUid(用于绑定反查)。</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>Apple:nimbus 验 ES256 identityToken + 校 iss/aud/exp → sub(忽略 email)</li>
|
|
|
+ * <li>Google:tokeninfo HTTP 验真 + 校 audience → sub</li>
|
|
|
+ * <li>LINE:v2/profile HTTP(Bearer)→ userId</li>
|
|
|
+ * </ul>
|
|
|
+ * <p>注:Google 走 tokeninfo 而非 firebase-admin,避免 FirebaseApp+服务账号初始化;各家真实 token + clientId 需联调验证。</p>
|
|
|
+ *
|
|
|
+ * @author foodie
|
|
|
+ * @date 2026-07-30
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class OAuthVerifyService {
|
|
|
+
|
|
|
+ @Value("${oauth.apple.client-id}")
|
|
|
+ private String appleClientId;
|
|
|
+ @Value("${oauth.apple.jwks-url}")
|
|
|
+ private String appleJwksUrl;
|
|
|
+ @Value("${oauth.google.client-id}")
|
|
|
+ private String googleClientId;
|
|
|
+ @Value("${oauth.google.tokeninfo-url}")
|
|
|
+ private String googleTokeninfoUrl;
|
|
|
+ @Value("${oauth.line.profile-url}")
|
|
|
+ private String lineProfileUrl;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验 provider 凭证,返回稳定的 providerUid。
|
|
|
+ *
|
|
|
+ * @param provider apple/google/line
|
|
|
+ * @param credential Apple identityToken / Google idToken / LINE accessToken
|
|
|
+ */
|
|
|
+ public String verify(String provider, String credential) {
|
|
|
+ if (provider == null || provider.isEmpty()) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.provider.blank"));
|
|
|
+ }
|
|
|
+ if (credential == null || credential.isEmpty()) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.credential.blank"));
|
|
|
+ }
|
|
|
+ switch (provider) {
|
|
|
+ case "apple":
|
|
|
+ return verifyApple(credential);
|
|
|
+ case "google":
|
|
|
+ return verifyGoogle(credential);
|
|
|
+ case "line":
|
|
|
+ return verifyLine(credential);
|
|
|
+ default:
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Apple:验 ES256 identityToken,返回 sub(不使用邮箱信息)。 */
|
|
|
+ private String verifyApple(String idToken) {
|
|
|
+ try {
|
|
|
+ SignedJWT jwt = SignedJWT.parse(idToken);
|
|
|
+ String kid = jwt.getHeader().getKeyID();
|
|
|
+ // 拉取 Apple 公钥(JWKS)并按 kid 匹配
|
|
|
+ JWKSet jwkSet = JWKSet.load(new URL(appleJwksUrl));
|
|
|
+ JWK jwk = jwkSet.getKeyByKeyId(kid);
|
|
|
+ if (jwk == null) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
|
|
|
+ }
|
|
|
+ // Apple identityToken 实测 alg=RS256(RSA);按 JWK 类型选验签器,兼容 EC
|
|
|
+ JWSVerifier verifier;
|
|
|
+ if (jwk instanceof RSAKey) {
|
|
|
+ verifier = new RSASSAVerifier(((RSAKey) jwk).toRSAPublicKey());
|
|
|
+ } else if (jwk instanceof ECKey) {
|
|
|
+ verifier = new ECDSAVerifier(((ECKey) jwk).toECPublicKey());
|
|
|
+ } else {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
|
|
|
+ }
|
|
|
+ if (!jwt.verify(verifier)) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
|
|
|
+ }
|
|
|
+ JWTClaimsSet claims = jwt.getJWTClaimsSet();
|
|
|
+ if (!"https://appleid.apple.com".equals(claims.getIssuer())) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
|
|
|
+ }
|
|
|
+ List<String> aud = claims.getAudience();
|
|
|
+ if (aud == null || !aud.contains(appleClientId)) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Apple"));
|
|
|
+ }
|
|
|
+ Date exp = claims.getExpirationTime();
|
|
|
+ if (exp == null || exp.before(new Date())) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.expired", "Apple"));
|
|
|
+ }
|
|
|
+ String sub = claims.getSubject();
|
|
|
+ if (sub == null || sub.isEmpty()) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
|
|
|
+ }
|
|
|
+ return sub;
|
|
|
+ } catch (ServiceException se) {
|
|
|
+ throw se;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple", e.getMessage()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
|
|
|
+ private String verifyGoogle(String idToken) {
|
|
|
+ try {
|
|
|
+ String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
|
|
|
+ JSONObject json = JSONObject.parseObject(httpGet(url, null));
|
|
|
+ if (json == null || json.containsKey("error") || json.containsKey("error_description")) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
|
|
|
+ }
|
|
|
+ String aud = json.getString("aud");
|
|
|
+ if (!googleClientId.equals(aud)) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Google"));
|
|
|
+ }
|
|
|
+ String sub = json.getString("sub");
|
|
|
+ if (sub == null || sub.isEmpty()) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
|
|
|
+ }
|
|
|
+ return sub;
|
|
|
+ } catch (ServiceException se) {
|
|
|
+ throw se;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google", e.getMessage()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** LINE:v2/profile HTTP(Bearer accessToken),返回 userId。 */
|
|
|
+ private String verifyLine(String accessToken) {
|
|
|
+ try {
|
|
|
+ JSONObject json = JSONObject.parseObject(httpGet(lineProfileUrl, accessToken));
|
|
|
+ if (json == null) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
|
|
|
+ }
|
|
|
+ String userId = json.getString("userId");
|
|
|
+ if (userId == null || userId.isEmpty()) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
|
|
|
+ }
|
|
|
+ return userId;
|
|
|
+ } catch (ServiceException se) {
|
|
|
+ throw se;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "LINE", e.getMessage()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 简单 GET;bearer 非空时带 Authorization 头(LINE 用)。带超时,防止 provider 慢/不可达拖垮线程。 */
|
|
|
+ private String httpGet(String url, String bearer) throws Exception {
|
|
|
+ try (CloseableHttpClient client = HttpClients.createDefault()) {
|
|
|
+ HttpGet get = new HttpGet(url);
|
|
|
+ get.setConfig(RequestConfig.custom()
|
|
|
+ .setConnectTimeout(5000)
|
|
|
+ .setSocketTimeout(10000)
|
|
|
+ .setConnectionRequestTimeout(5000)
|
|
|
+ .build());
|
|
|
+ if (bearer != null && !bearer.isEmpty()) {
|
|
|
+ get.setHeader("Authorization", "Bearer " + bearer);
|
|
|
+ }
|
|
|
+ try (CloseableHttpResponse resp = client.execute(get)) {
|
|
|
+ return EntityUtils.toString(resp.getEntity(), "UTF-8");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|