| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- 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.client.methods.HttpPost;
- import org.apache.http.client.entity.UrlEncodedFormEntity;
- import org.apache.http.message.BasicNameValuePair;
- import org.apache.http.NameValuePair;
- import org.apache.http.impl.client.CloseableHttpClient;
- import org.apache.http.impl.client.HttpClients;
- import org.apache.http.util.EntityUtils;
- import org.slf4j.Logger;
- import org.slf4j.LoggerFactory;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.stereotype.Service;
- import java.net.URL;
- import java.net.URLEncoder;
- import java.util.ArrayList;
- 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:code 换 access_token → v2/profile → userId</li>
- * </ul>
- * <p>注:Google 走 tokeninfo 而非 firebase-admin,避免 FirebaseApp+服务账号初始化;各家真实 token + clientId 需联调验证。</p>
- *
- * @author foodie
- * @date 2026-07-30
- */
- @Service
- public class OAuthVerifyService {
- private static final Logger log = LoggerFactory.getLogger(OAuthVerifyService.class);
- @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;
- @Value("${oauth.line.token-url}")
- private String lineTokenUrl;
- @Value("${oauth.line.client-id}")
- private String lineClientId;
- @Value("${oauth.line.client-secret}")
- private String lineClientSecret;
- @Value("${oauth.line.redirect-uri}")
- private String lineRedirectUri;
- /**
- * 校验 provider 凭证,返回稳定的 providerUid。
- *
- * @param provider apple/google/line
- * @param credential Apple identityToken / Google idToken / LINE authorization code
- */
- 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"));
- }
- log.info("[OAuth] 校验凭证 provider={}", provider);
- log.debug("[OAuth] {} credential={}", provider, credential);
- 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 {
- log.debug("[OAuth][Apple] identityToken={}", idToken);
- 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) {
- log.warn("[OAuth][Apple] 公钥未匹配 kid={}", kid);
- 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 {
- log.warn("[OAuth][Apple] 不支持的公钥类型 kid={}", kid);
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
- }
- if (!jwt.verify(verifier)) {
- log.warn("[OAuth][Apple] 验签失败 kid={}", kid);
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
- }
- JWTClaimsSet claims = jwt.getJWTClaimsSet();
- if (!"https://appleid.apple.com".equals(claims.getIssuer())) {
- log.warn("[OAuth][Apple] iss 非法: {}", claims.getIssuer());
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
- }
- List<String> aud = claims.getAudience();
- if (aud == null || !aud.contains(appleClientId)) {
- log.warn("[OAuth][Apple] aud 不匹配: expected={}, got={}", appleClientId, aud);
- throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Apple"));
- }
- Date exp = claims.getExpirationTime();
- if (exp == null || exp.before(new Date())) {
- log.warn("[OAuth][Apple] token 已过期: exp={}", exp);
- throw new ServiceException(MessageUtils.message("no.oauth.token.expired", "Apple"));
- }
- String sub = claims.getSubject();
- if (sub == null || sub.isEmpty()) {
- log.warn("[OAuth][Apple] sub 为空");
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
- }
- log.info("[OAuth][Apple] 校验通过 sub={}", sub);
- return sub;
- } catch (ServiceException se) {
- throw se;
- } catch (Exception e) {
- log.error("[OAuth][Apple] 校验异常", e);
- throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple", e.getMessage()));
- }
- }
- /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
- private String verifyGoogle(String idToken) {
- try {
- log.debug("[OAuth][Google] idToken={}", idToken);
- String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
- JSONObject json = JSONObject.parseObject(httpGet(url, null));
- log.debug("[OAuth][Google] tokeninfo 返回: {}", json);
- if (json == null || json.containsKey("error") || json.containsKey("error_description")) {
- log.warn("[OAuth][Google] tokeninfo 返回错误: {}", json);
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
- }
- String aud = json.getString("aud");
- if (!googleClientId.equals(aud)) {
- log.warn("[OAuth][Google] aud 不匹配: expected={}, got={}", googleClientId, aud);
- throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Google"));
- }
- String sub = json.getString("sub");
- if (sub == null || sub.isEmpty()) {
- log.warn("[OAuth][Google] sub 为空");
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
- }
- log.info("[OAuth][Google] 校验通过 sub={}", sub);
- return sub;
- } catch (ServiceException se) {
- throw se;
- } catch (Exception e) {
- log.error("[OAuth][Google] 校验异常", e);
- throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google", e.getMessage()));
- }
- }
- /**
- * LINE:authorization code → 换 access_token → v2/profile 取 userId。
- *
- * <p>标准 LINE Login 流程:前端拿到授权 code 后传给后端,后端用 code + clientSecret + clientId
- * 向 {@code https://api.line.me/oauth2/v2.1/token} 换 access_token(form-urlencoded),
- * 再用 access_token 调 v2/profile 取稳定的 userId 作为 providerUid。
- *
- * @param code LINE 授权码(前端授权后获得,一次性)
- * @return LINE userId
- */
- private String verifyLine(String code) {
- try {
- // code 一次性、短期有效;client_secret 永不记录日志
- log.info("[OAuth][LINE] 换 token 请求: grant_type=authorization_code, client_id={}, redirect_uri={}, code={}...(len={})",
- lineClientId, lineRedirectUri, safePrefix(code), code == null ? 0 : code.length());
- // 1. code 换 access_token(httpPostForm 内会打印 HTTP 状态码 + LINE 原始响应体,含 error/error_description)
- JSONObject token = JSONObject.parseObject(httpPostForm(lineTokenUrl, new String[][]{
- {"grant_type", "authorization_code"},
- {"code", code},
- {"redirect_uri", lineRedirectUri},
- {"client_id", lineClientId},
- {"client_secret", lineClientSecret}
- }));
- if (token == null || token.getString("access_token") == null) {
- log.warn("[OAuth][LINE] 换 token 失败(响应无 access_token): {}", token);
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
- }
- String accessToken = token.getString("access_token");
- log.info("[OAuth][LINE] 换 token 成功: access_token={}...(len={}), scope={}",
- safePrefix(accessToken), accessToken.length(), token.getString("scope"));
- // 2. access_token 换用户信息(httpGet 内会打印 HTTP 状态码 + profile 原始响应体)
- JSONObject profile = JSONObject.parseObject(httpGet(lineProfileUrl, accessToken));
- if (profile == null) {
- log.warn("[OAuth][LINE] profile 返回空");
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
- }
- String userId = profile.getString("userId");
- if (userId == null || userId.isEmpty()) {
- log.warn("[OAuth][LINE] 无 userId: {}", profile);
- throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
- }
- log.info("[OAuth][LINE] 校验通过 userId={}", userId);
- return userId;
- } catch (ServiceException se) {
- throw se;
- } catch (Exception e) {
- log.error("[OAuth][LINE] 校验异常", 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)) {
- int status = resp.getStatusLine().getStatusCode();
- String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
- // google tokeninfo 的 url 含 id_token,只记 host 避免 JWT 刷屏
- String host;
- try {
- host = new URL(url).getHost();
- } catch (Exception e) {
- host = url;
- }
- log.info("[OAuth][HTTP] GET host={} status={} body={}", host, status, body);
- return body;
- }
- }
- }
- /** 简单 POST application/x-www-form-urlencoded(LINE 换 token 用)。带超时,防止 LINE 慢/不可达拖垮线程。 */
- private String httpPostForm(String url, String[][] form) throws Exception {
- try (CloseableHttpClient client = HttpClients.createDefault()) {
- HttpPost post = new HttpPost(url);
- post.setConfig(RequestConfig.custom()
- .setConnectTimeout(5000)
- .setSocketTimeout(10000)
- .setConnectionRequestTimeout(5000)
- .build());
- List<NameValuePair> pairs = new ArrayList<>();
- for (String[] kv : form) {
- pairs.add(new BasicNameValuePair(kv[0], kv[1]));
- }
- post.setEntity(new UrlEncodedFormEntity(pairs, "UTF-8"));
- try (CloseableHttpResponse resp = client.execute(post)) {
- int status = resp.getStatusLine().getStatusCode();
- String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
- // LINE 换 token 失败时 status=400,body 含 error/error_description,是定位根因的关键
- log.info("[OAuth][HTTP] POST {} status={} body={}", url, status, body);
- return body;
- }
- }
- }
- /** 取前 8 字符做日志脱敏(code/access_token 不全文打印)。 */
- private static String safePrefix(String s) {
- return s == null ? "" : s.substring(0, Math.min(8, s.length()));
- }
- }
|