OAuthVerifyService.java 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. package com.ruoyi.app.utils.oauth;
  2. import com.alibaba.fastjson2.JSONObject;
  3. import com.nimbusds.jose.JWSVerifier;
  4. import com.nimbusds.jose.crypto.ECDSAVerifier;
  5. import com.nimbusds.jose.crypto.RSASSAVerifier;
  6. import com.nimbusds.jose.jwk.ECKey;
  7. import com.nimbusds.jose.jwk.JWK;
  8. import com.nimbusds.jose.jwk.JWKSet;
  9. import com.nimbusds.jose.jwk.RSAKey;
  10. import com.nimbusds.jwt.JWTClaimsSet;
  11. import com.nimbusds.jwt.SignedJWT;
  12. import com.ruoyi.common.exception.ServiceException;
  13. import com.ruoyi.common.utils.MessageUtils;
  14. import org.apache.http.client.config.RequestConfig;
  15. import org.apache.http.client.methods.CloseableHttpResponse;
  16. import org.apache.http.client.methods.HttpGet;
  17. import org.apache.http.client.methods.HttpPost;
  18. import org.apache.http.client.entity.UrlEncodedFormEntity;
  19. import org.apache.http.message.BasicNameValuePair;
  20. import org.apache.http.NameValuePair;
  21. import org.apache.http.impl.client.CloseableHttpClient;
  22. import org.apache.http.impl.client.HttpClients;
  23. import org.apache.http.util.EntityUtils;
  24. import org.slf4j.Logger;
  25. import org.slf4j.LoggerFactory;
  26. import org.springframework.beans.factory.annotation.Value;
  27. import org.springframework.stereotype.Service;
  28. import java.net.URL;
  29. import java.net.URLEncoder;
  30. import java.util.ArrayList;
  31. import java.util.Date;
  32. import java.util.List;
  33. /**
  34. * 三方登录凭证校验(017-oauth-login)。
  35. * <p>Apple/Google/LINE 各自校验前端传来的凭证,返回稳定的 providerUid(用于绑定反查)。</p>
  36. * <ul>
  37. * <li>Apple:nimbus 验 ES256 identityToken + 校 iss/aud/exp → sub(忽略 email)</li>
  38. * <li>Google:tokeninfo HTTP 验真 + 校 audience → sub</li>
  39. * <li>LINE:code 换 access_token → v2/profile → userId</li>
  40. * </ul>
  41. * <p>注:Google 走 tokeninfo 而非 firebase-admin,避免 FirebaseApp+服务账号初始化;各家真实 token + clientId 需联调验证。</p>
  42. *
  43. * @author foodie
  44. * @date 2026-07-30
  45. */
  46. @Service
  47. public class OAuthVerifyService {
  48. private static final Logger log = LoggerFactory.getLogger(OAuthVerifyService.class);
  49. @Value("${oauth.apple.client-id}")
  50. private String appleClientId;
  51. @Value("${oauth.apple.jwks-url}")
  52. private String appleJwksUrl;
  53. @Value("${oauth.google.client-id}")
  54. private String googleClientId;
  55. @Value("${oauth.google.tokeninfo-url}")
  56. private String googleTokeninfoUrl;
  57. @Value("${oauth.line.profile-url}")
  58. private String lineProfileUrl;
  59. @Value("${oauth.line.token-url}")
  60. private String lineTokenUrl;
  61. @Value("${oauth.line.client-id}")
  62. private String lineClientId;
  63. @Value("${oauth.line.client-secret}")
  64. private String lineClientSecret;
  65. @Value("${oauth.line.redirect-uri}")
  66. private String lineRedirectUri;
  67. /**
  68. * 校验 provider 凭证,返回稳定的 providerUid。
  69. *
  70. * @param provider apple/google/line
  71. * @param credential Apple identityToken / Google idToken / LINE authorization code
  72. */
  73. public String verify(String provider, String credential) {
  74. if (provider == null || provider.isEmpty()) {
  75. throw new ServiceException(MessageUtils.message("no.oauth.provider.blank"));
  76. }
  77. if (credential == null || credential.isEmpty()) {
  78. throw new ServiceException(MessageUtils.message("no.oauth.credential.blank"));
  79. }
  80. log.info("[OAuth] 校验凭证 provider={}", provider);
  81. log.debug("[OAuth] {} credential={}", provider, credential);
  82. switch (provider) {
  83. case "apple":
  84. return verifyApple(credential);
  85. case "google":
  86. return verifyGoogle(credential);
  87. case "line":
  88. return verifyLine(credential);
  89. default:
  90. throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
  91. }
  92. }
  93. /** Apple:验 ES256 identityToken,返回 sub(不使用邮箱信息)。 */
  94. private String verifyApple(String idToken) {
  95. try {
  96. log.debug("[OAuth][Apple] identityToken={}", idToken);
  97. SignedJWT jwt = SignedJWT.parse(idToken);
  98. String kid = jwt.getHeader().getKeyID();
  99. // 拉取 Apple 公钥(JWKS)并按 kid 匹配
  100. JWKSet jwkSet = JWKSet.load(new URL(appleJwksUrl));
  101. JWK jwk = jwkSet.getKeyByKeyId(kid);
  102. if (jwk == null) {
  103. log.warn("[OAuth][Apple] 公钥未匹配 kid={}", kid);
  104. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
  105. }
  106. // Apple identityToken 实测 alg=RS256(RSA);按 JWK 类型选验签器,兼容 EC
  107. JWSVerifier verifier;
  108. if (jwk instanceof RSAKey) {
  109. verifier = new RSASSAVerifier(((RSAKey) jwk).toRSAPublicKey());
  110. } else if (jwk instanceof ECKey) {
  111. verifier = new ECDSAVerifier(((ECKey) jwk).toECPublicKey());
  112. } else {
  113. log.warn("[OAuth][Apple] 不支持的公钥类型 kid={}", kid);
  114. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
  115. }
  116. if (!jwt.verify(verifier)) {
  117. log.warn("[OAuth][Apple] 验签失败 kid={}", kid);
  118. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
  119. }
  120. JWTClaimsSet claims = jwt.getJWTClaimsSet();
  121. if (!"https://appleid.apple.com".equals(claims.getIssuer())) {
  122. log.warn("[OAuth][Apple] iss 非法: {}", claims.getIssuer());
  123. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
  124. }
  125. List<String> aud = claims.getAudience();
  126. if (aud == null || !aud.contains(appleClientId)) {
  127. log.warn("[OAuth][Apple] aud 不匹配: expected={}, got={}", appleClientId, aud);
  128. throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Apple"));
  129. }
  130. Date exp = claims.getExpirationTime();
  131. if (exp == null || exp.before(new Date())) {
  132. log.warn("[OAuth][Apple] token 已过期: exp={}", exp);
  133. throw new ServiceException(MessageUtils.message("no.oauth.token.expired", "Apple"));
  134. }
  135. String sub = claims.getSubject();
  136. if (sub == null || sub.isEmpty()) {
  137. log.warn("[OAuth][Apple] sub 为空");
  138. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
  139. }
  140. log.info("[OAuth][Apple] 校验通过 sub={}", sub);
  141. return sub;
  142. } catch (ServiceException se) {
  143. throw se;
  144. } catch (Exception e) {
  145. log.error("[OAuth][Apple] 校验异常", e);
  146. throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple", e.getMessage()));
  147. }
  148. }
  149. /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
  150. private String verifyGoogle(String idToken) {
  151. try {
  152. log.debug("[OAuth][Google] idToken={}", idToken);
  153. String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
  154. JSONObject json = JSONObject.parseObject(httpGet(url, null));
  155. log.debug("[OAuth][Google] tokeninfo 返回: {}", json);
  156. if (json == null || json.containsKey("error") || json.containsKey("error_description")) {
  157. log.warn("[OAuth][Google] tokeninfo 返回错误: {}", json);
  158. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
  159. }
  160. String aud = json.getString("aud");
  161. if (!googleClientId.equals(aud)) {
  162. log.warn("[OAuth][Google] aud 不匹配: expected={}, got={}", googleClientId, aud);
  163. throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Google"));
  164. }
  165. String sub = json.getString("sub");
  166. if (sub == null || sub.isEmpty()) {
  167. log.warn("[OAuth][Google] sub 为空");
  168. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
  169. }
  170. log.info("[OAuth][Google] 校验通过 sub={}", sub);
  171. return sub;
  172. } catch (ServiceException se) {
  173. throw se;
  174. } catch (Exception e) {
  175. log.error("[OAuth][Google] 校验异常", e);
  176. throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google", e.getMessage()));
  177. }
  178. }
  179. /**
  180. * LINE:authorization code → 换 access_token → v2/profile 取 userId。
  181. *
  182. * <p>标准 LINE Login 流程:前端拿到授权 code 后传给后端,后端用 code + clientSecret + clientId
  183. * 向 {@code https://api.line.me/oauth2/v2.1/token} 换 access_token(form-urlencoded),
  184. * 再用 access_token 调 v2/profile 取稳定的 userId 作为 providerUid。
  185. *
  186. * @param code LINE 授权码(前端授权后获得,一次性)
  187. * @return LINE userId
  188. */
  189. private String verifyLine(String code) {
  190. try {
  191. // code 一次性、短期有效;client_secret 永不记录日志
  192. log.info("[OAuth][LINE] 换 token 请求: grant_type=authorization_code, client_id={}, redirect_uri={}, code={}...(len={})",
  193. lineClientId, lineRedirectUri, safePrefix(code), code == null ? 0 : code.length());
  194. // 1. code 换 access_token(httpPostForm 内会打印 HTTP 状态码 + LINE 原始响应体,含 error/error_description)
  195. JSONObject token = JSONObject.parseObject(httpPostForm(lineTokenUrl, new String[][]{
  196. {"grant_type", "authorization_code"},
  197. {"code", code},
  198. {"redirect_uri", lineRedirectUri},
  199. {"client_id", lineClientId},
  200. {"client_secret", lineClientSecret}
  201. }));
  202. if (token == null || token.getString("access_token") == null) {
  203. log.warn("[OAuth][LINE] 换 token 失败(响应无 access_token): {}", token);
  204. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
  205. }
  206. String accessToken = token.getString("access_token");
  207. log.info("[OAuth][LINE] 换 token 成功: access_token={}...(len={}), scope={}",
  208. safePrefix(accessToken), accessToken.length(), token.getString("scope"));
  209. // 2. access_token 换用户信息(httpGet 内会打印 HTTP 状态码 + profile 原始响应体)
  210. JSONObject profile = JSONObject.parseObject(httpGet(lineProfileUrl, accessToken));
  211. if (profile == null) {
  212. log.warn("[OAuth][LINE] profile 返回空");
  213. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
  214. }
  215. String userId = profile.getString("userId");
  216. if (userId == null || userId.isEmpty()) {
  217. log.warn("[OAuth][LINE] 无 userId: {}", profile);
  218. throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
  219. }
  220. log.info("[OAuth][LINE] 校验通过 userId={}", userId);
  221. return userId;
  222. } catch (ServiceException se) {
  223. throw se;
  224. } catch (Exception e) {
  225. log.error("[OAuth][LINE] 校验异常", e);
  226. throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "LINE", e.getMessage()));
  227. }
  228. }
  229. /** 简单 GET;bearer 非空时带 Authorization 头(LINE 用)。带超时,防止 provider 慢/不可达拖垮线程。 */
  230. private String httpGet(String url, String bearer) throws Exception {
  231. try (CloseableHttpClient client = HttpClients.createDefault()) {
  232. HttpGet get = new HttpGet(url);
  233. get.setConfig(RequestConfig.custom()
  234. .setConnectTimeout(5000)
  235. .setSocketTimeout(10000)
  236. .setConnectionRequestTimeout(5000)
  237. .build());
  238. if (bearer != null && !bearer.isEmpty()) {
  239. get.setHeader("Authorization", "Bearer " + bearer);
  240. }
  241. try (CloseableHttpResponse resp = client.execute(get)) {
  242. int status = resp.getStatusLine().getStatusCode();
  243. String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
  244. // google tokeninfo 的 url 含 id_token,只记 host 避免 JWT 刷屏
  245. String host;
  246. try {
  247. host = new URL(url).getHost();
  248. } catch (Exception e) {
  249. host = url;
  250. }
  251. log.info("[OAuth][HTTP] GET host={} status={} body={}", host, status, body);
  252. return body;
  253. }
  254. }
  255. }
  256. /** 简单 POST application/x-www-form-urlencoded(LINE 换 token 用)。带超时,防止 LINE 慢/不可达拖垮线程。 */
  257. private String httpPostForm(String url, String[][] form) throws Exception {
  258. try (CloseableHttpClient client = HttpClients.createDefault()) {
  259. HttpPost post = new HttpPost(url);
  260. post.setConfig(RequestConfig.custom()
  261. .setConnectTimeout(5000)
  262. .setSocketTimeout(10000)
  263. .setConnectionRequestTimeout(5000)
  264. .build());
  265. List<NameValuePair> pairs = new ArrayList<>();
  266. for (String[] kv : form) {
  267. pairs.add(new BasicNameValuePair(kv[0], kv[1]));
  268. }
  269. post.setEntity(new UrlEncodedFormEntity(pairs, "UTF-8"));
  270. try (CloseableHttpResponse resp = client.execute(post)) {
  271. int status = resp.getStatusLine().getStatusCode();
  272. String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
  273. // LINE 换 token 失败时 status=400,body 含 error/error_description,是定位根因的关键
  274. log.info("[OAuth][HTTP] POST {} status={} body={}", url, status, body);
  275. return body;
  276. }
  277. }
  278. }
  279. /** 取前 8 字符做日志脱敏(code/access_token 不全文打印)。 */
  280. private static String safePrefix(String s) {
  281. return s == null ? "" : s.substring(0, Math.min(8, s.length()));
  282. }
  283. }