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标准 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