feat(可控开源社区): 支持AIAgent登录Token获取 #7
|
|
@ -51,7 +51,14 @@
|
|||
<groupId>com.microservices</groupId>
|
||||
<artifactId>microservices-common-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- Microservices Common HttpClient -->
|
||||
<dependency>
|
||||
<groupId>com.microservices</groupId>
|
||||
<artifactId>microservices-common-httpClient</artifactId>
|
||||
<version>3.6.2</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.microservices.auth.controller;
|
||||
|
||||
import com.microservices.auth.service.AiAgentTokenService;
|
||||
import com.microservices.auth.vo.AIAgentTokenVo;
|
||||
import com.microservices.common.core.domain.R;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Casdoor 预置账号 token 中转接口
|
||||
*
|
||||
* @author microservices
|
||||
*/
|
||||
@RestController
|
||||
public class AiAgentController
|
||||
{
|
||||
@Autowired
|
||||
private AiAgentTokenService aiAgentTokenService;
|
||||
|
||||
/**
|
||||
* 获取 Astron Agent(Casdoor)access_token,前端凭此 token 自带 Bearer 头直连 Astron 业务接口。
|
||||
* 网关路由后完整路径为 /auth/casdoor/token。
|
||||
*/
|
||||
@PostMapping("/aiAgent/login")
|
||||
public R<AIAgentTokenVo> token()
|
||||
{
|
||||
return R.ok(aiAgentTokenService.getAccessToken());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
package com.microservices.auth.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.microservices.auth.vo.AIAgentTokenVo;
|
||||
import com.microservices.common.core.exception.ServiceException;
|
||||
import com.microservices.common.core.utils.StringUtils;
|
||||
import com.microservices.common.httpClient.service.HttpAPIService;
|
||||
import com.microservices.common.redis.service.RedisService;
|
||||
import org.apache.http.HttpEntity;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* AIAgent 预置账号 token 中转服务(Token 中转形态)。
|
||||
* <p>
|
||||
* 托管预置账号向 AIAgent 换取 access_token,前端凭此 token 直连 Astron Agent 业务接口。
|
||||
* 7 天有效期的 token 走 Redis 缓存;并发首登通过 SETNX 分布式锁 + 双重检查防止击穿。
|
||||
*
|
||||
* @author microservices
|
||||
*/
|
||||
@Service
|
||||
public class AiAgentTokenService {
|
||||
private static final Logger log = LoggerFactory.getLogger(AiAgentTokenService.class);
|
||||
|
||||
/**
|
||||
* AIAgent 服务地址
|
||||
*/
|
||||
@Value("${aiAgent.url:}")
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 换 token 端点,留空则取 ${url}/api/login/oauth/access_token
|
||||
*/
|
||||
@Value("${aiAgent.token-endpoint:}")
|
||||
private String tokenEndpoint;
|
||||
|
||||
/**
|
||||
* OAuth client_id
|
||||
*/
|
||||
@Value("${aiAgent.client-id:4b25ca036879216a020a}")
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 预置账号
|
||||
*/
|
||||
@Value("${aiAgent.username:}")
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 预置密码(敏感,走配置中心,勿入库)
|
||||
*/
|
||||
@Value("${aiAgent.password:}")
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 授权范围
|
||||
*/
|
||||
@Value("${aiAgent.scope:profile}")
|
||||
private String scope = "profile";
|
||||
|
||||
/**
|
||||
* 提前 N 秒视为过期,避免边界
|
||||
*/
|
||||
@Value("${aiAgent.token-advance-expire-seconds:60}")
|
||||
private long tokenAdvanceExpireSeconds = 60L;
|
||||
|
||||
/**
|
||||
* token 缓存 key 前缀,完整 key:AIAgent:token:{clientId}:{username}
|
||||
*/
|
||||
private static final String CACHE_KEY_PREFIX = "AIAgent:token:";
|
||||
|
||||
/**
|
||||
* 分布式锁 key 前缀,完整 key:AIAgent:lock:{username}
|
||||
*/
|
||||
private static final String LOCK_KEY_PREFIX = "AIAgent:lock:";
|
||||
|
||||
/**
|
||||
* 锁自动释放时间(秒),防止持锁线程异常导致死锁
|
||||
*/
|
||||
private static final long LOCK_TTL_SECONDS = 30L;
|
||||
|
||||
/**
|
||||
* 未抢到锁时的单次等待时间(毫秒)
|
||||
*/
|
||||
private static final long LOCK_WAIT_MILLIS = 300L;
|
||||
|
||||
/**
|
||||
* 未抢到锁时的最大等待次数
|
||||
*/
|
||||
private static final int LOCK_WAIT_MAX_RETRIES = 10;
|
||||
|
||||
@Autowired
|
||||
private HttpAPIService httpAPIService;
|
||||
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
/**
|
||||
* 对外主入口:缓存命中且未过期直接返回;已过期则 refresh;无缓存则 password 登录。
|
||||
*/
|
||||
public AIAgentTokenVo getAccessToken() {
|
||||
AIAgentTokenVo cached = getCache();
|
||||
if (cached != null && !isExpired(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
String lockKey = lockKey();
|
||||
String lockValue = UUID.randomUUID().toString();
|
||||
if (tryLock(lockKey, lockValue)) {
|
||||
try {
|
||||
// 双重检查:抢锁期间可能已有其他线程写入缓存
|
||||
cached = getCache();
|
||||
if (cached != null && !isExpired(cached)) {
|
||||
return cached;
|
||||
}
|
||||
// 已有缓存但临近过期 → 优先 refresh,失败回退 password 登录
|
||||
if (cached != null && StringUtils.isNotEmpty(cached.getRefreshToken())) {
|
||||
try {
|
||||
return refreshByRefreshToken(cached.getRefreshToken());
|
||||
} catch (Exception e) {
|
||||
log.warn("AIAgent refresh 续期失败,回退到密码登录:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return loginByPassword();
|
||||
} finally {
|
||||
unlock(lockKey, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
// 未抢到锁:等待持锁线程写入缓存后复用,避免并发重复登录
|
||||
for (int i = 0; i < LOCK_WAIT_MAX_RETRIES; i++) {
|
||||
sleep(LOCK_WAIT_MILLIS);
|
||||
cached = getCache();
|
||||
if (cached != null && !isExpired(cached)) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
// 极端情况下仍未拿到缓存:降级直接登录,保证请求可用
|
||||
log.warn("AIAgent token 锁等待超时,降级直接登录");
|
||||
return loginByPassword();
|
||||
}
|
||||
|
||||
/**
|
||||
* password grant 换取 token 并写缓存。
|
||||
*/
|
||||
public AIAgentTokenVo loginByPassword() {
|
||||
List<NameValuePair> form = new ArrayList<>();
|
||||
form.add(new BasicNameValuePair("grant_type", "password"));
|
||||
form.add(new BasicNameValuePair("client_id", clientId));
|
||||
form.add(new BasicNameValuePair("username", username));
|
||||
form.add(new BasicNameValuePair("password", password));
|
||||
form.add(new BasicNameValuePair("scope", scope));
|
||||
return requestAndCache(form, "password");
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh grant 续期并更新缓存;失败抛异常由调用方回退处理。
|
||||
*/
|
||||
public AIAgentTokenVo refreshByRefreshToken(String refreshToken) {
|
||||
List<NameValuePair> form = new ArrayList<>();
|
||||
form.add(new BasicNameValuePair("grant_type", "refresh_token"));
|
||||
form.add(new BasicNameValuePair("client_id", clientId));
|
||||
form.add(new BasicNameValuePair("refresh_token", refreshToken));
|
||||
form.add(new BasicNameValuePair("scope", scope));
|
||||
return requestAndCache(form, "refresh_token");
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 AIAgent 换 token 端点并写缓存。
|
||||
*/
|
||||
private AIAgentTokenVo requestAndCache(List<NameValuePair> form, String grantType) {
|
||||
AIAgentTokenVo token = requestToken(form, grantType);
|
||||
setCache(token);
|
||||
return token;
|
||||
}
|
||||
private String resolveTokenEndpoint()
|
||||
{
|
||||
return StringUtils.isNotEmpty(tokenEndpoint) ? tokenEndpoint : url + "/api/login/oauth/access_token";
|
||||
}
|
||||
/**
|
||||
* 调用 AIAgent 换 token 端点,解析响应。
|
||||
* <p>
|
||||
* 注意:请求体为 form-urlencoded;AIAgent 即便业务失败也常返回 HTTP 200,
|
||||
* 必须解析 body 中的 error 字段判断成败。
|
||||
*/
|
||||
private AIAgentTokenVo requestToken(List<NameValuePair> form, String grantType) {
|
||||
ensureConfigured();
|
||||
HttpEntity entity = new UrlEncodedFormEntity(form, StandardCharsets.UTF_8);
|
||||
JSONObject resp;
|
||||
try {
|
||||
resp = httpAPIService.doPostFormData(resolveTokenEndpoint(), entity, null);
|
||||
} catch (Exception e) {
|
||||
log.error("调用 AIAgent {} 端点失败:{}", grantType, e.getMessage());
|
||||
throw new ServiceException("第三方登录服务暂不可用");
|
||||
}
|
||||
if (resp == null) {
|
||||
throw new ServiceException("第三方登录服务响应为空");
|
||||
}
|
||||
if (resp.containsKey("error")) {
|
||||
String desc = resp.getString("error_description");
|
||||
if (StringUtils.isEmpty(desc)) {
|
||||
desc = resp.getString("error");
|
||||
}
|
||||
log.error("AIAgent {} 失败:{}", grantType, resp);
|
||||
throw new ServiceException("第三方登录失败:" + desc);
|
||||
}
|
||||
String accessToken = resp.getString("access_token");
|
||||
if (StringUtils.isEmpty(accessToken)) {
|
||||
log.error("AIAgent {} 响应缺少 access_token:{}", grantType, resp);
|
||||
throw new ServiceException("第三方登录失败:未获取到 access_token");
|
||||
}
|
||||
Long expiresInObj = resp.getLong("expires_in");
|
||||
long expiresIn = expiresInObj == null ? 0L : expiresInObj;
|
||||
String tokenType = resp.getString("token_type");
|
||||
if (StringUtils.isEmpty(tokenType)) {
|
||||
tokenType = "Bearer";
|
||||
}
|
||||
long expiresAt = System.currentTimeMillis() + (expiresIn > 0 ? expiresIn * 1000L : 0L);
|
||||
return new AIAgentTokenVo(accessToken, resp.getString("refresh_token"), tokenType, expiresAt);
|
||||
}
|
||||
|
||||
private void ensureConfigured() {
|
||||
if (StringUtils.isAnyBlank(url, clientId,
|
||||
username, password)) {
|
||||
throw new ServiceException("AIAgent 登录配置不完整,请检查配置中心");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 缓存 ----
|
||||
|
||||
private String cacheKey() {
|
||||
return CACHE_KEY_PREFIX + clientId + ":" + username;
|
||||
}
|
||||
|
||||
private AIAgentTokenVo getCache() {
|
||||
try {
|
||||
return redisService.getCacheObject(cacheKey());
|
||||
} catch (Exception e) {
|
||||
log.warn("读取 AIAgent token 缓存失败:{}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void setCache(AIAgentTokenVo token) {
|
||||
// TTL = expires_in - 提前量,与"视为过期"的时间点对齐
|
||||
long ttl = Math.max(1L, token.getExpiresIn() - tokenAdvanceExpireSeconds);
|
||||
redisService.setCacheObject(cacheKey(), token, ttl, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private boolean isExpired(AIAgentTokenVo token) {
|
||||
if (token == null || token.getExpiresAt() == null) {
|
||||
return true;
|
||||
}
|
||||
long boundary = token.getExpiresAt() - tokenAdvanceExpireSeconds * 1000L;
|
||||
return System.currentTimeMillis() >= boundary;
|
||||
}
|
||||
|
||||
// ---- 分布式锁(基于 Redis SETNX + TTL)----
|
||||
|
||||
private String lockKey() {
|
||||
return LOCK_KEY_PREFIX + username;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private boolean tryLock(String key, String value) {
|
||||
Boolean ok = redisService.redisTemplate.opsForValue()
|
||||
.setIfAbsent(key, value, LOCK_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
return Boolean.TRUE.equals(ok);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void unlock(String key, String value) {
|
||||
try {
|
||||
Object current = redisService.redisTemplate.opsForValue().get(key);
|
||||
if (value.equals(current)) {
|
||||
redisService.redisTemplate.delete(key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("释放 AIAgent token 锁失败:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void sleep(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.microservices.auth.vo;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Casdoor(Astron Agent)token 中转返回对象
|
||||
* <p>
|
||||
* 同时作为 Redis 缓存值:accessToken/refreshToken/tokenType/expiresAt 持久化,
|
||||
* expiresIn 由 expiresAt 实时计算。
|
||||
*
|
||||
* @author microservices
|
||||
*/
|
||||
public class AIAgentTokenVo implements Serializable
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 访问令牌 */
|
||||
private String accessToken;
|
||||
|
||||
/** 刷新令牌 */
|
||||
private String refreshToken;
|
||||
|
||||
/** 令牌类型,恒为 Bearer */
|
||||
private String tokenType;
|
||||
|
||||
/** 过期绝对时间戳(毫秒) */
|
||||
private Long expiresAt;
|
||||
|
||||
public AIAgentTokenVo()
|
||||
{
|
||||
}
|
||||
|
||||
public AIAgentTokenVo(String accessToken, String refreshToken, String tokenType, Long expiresAt)
|
||||
{
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.tokenType = tokenType;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public String getAccessToken()
|
||||
{
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public void setAccessToken(String accessToken)
|
||||
{
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
public String getRefreshToken()
|
||||
{
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken)
|
||||
{
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public String getTokenType()
|
||||
{
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public void setTokenType(String tokenType)
|
||||
{
|
||||
this.tokenType = tokenType;
|
||||
}
|
||||
|
||||
public Long getExpiresAt()
|
||||
{
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Long expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 距过期剩余秒数(实时计算,避免缓存命中后下发陈旧的剩余时间)
|
||||
*/
|
||||
public long getExpiresIn()
|
||||
{
|
||||
return expiresAt == null ? 0L : Math.max(0L, (expiresAt - System.currentTimeMillis()) / 1000L);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue