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)。 *

Apple/Google/LINE 各自校验前端传来的凭证,返回稳定的 providerUid(用于绑定反查)。

* *

注:Google 走 tokeninfo 而非 firebase-admin,避免 FirebaseApp+服务账号初始化;各家真实 token + clientId 需联调验证。

* * @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 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。 * *

标准 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 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())); } }