1040 lines
43 KiB
Markdown
1040 lines
43 KiB
Markdown
# 碳信网 SSO 认证模块详解
|
||
|
||
> 本文档基于 txw-sso 服务代码分析,详细说明注册登录流程、表结构定义、外部服务依赖。
|
||
|
||
---
|
||
|
||
## 一、模块定位
|
||
|
||
txw-sso 是碳信网项目的**统一认证服务**,负责:
|
||
- 用户账号密码登录/短信验证码登录
|
||
- OAuth2.0 令牌管理
|
||
- DID(去中心化身份)登录集成
|
||
- 图形验证码/短信验证码服务
|
||
- 账户锁定(防暴力破解)
|
||
|
||
---
|
||
|
||
## 二、核心表结构
|
||
|
||
### 2.1 OAuth2 令牌相关表
|
||
|
||
#### txw_sso_access_token(访问令牌表)
|
||
```sql
|
||
CREATE TABLE txw_sso_access_token (
|
||
uuid VARCHAR(64) PRIMARY KEY, -- 令牌UUID
|
||
yh_uuid VARCHAR(64), -- 用户UUID
|
||
qyuuid VARCHAR(64), -- 企业UUID
|
||
access_token VARCHAR(512), -- 访问令牌
|
||
refresh_token VARCHAR(512), -- 刷新令牌
|
||
clientid VARCHAR(64), -- 客户端ID
|
||
sqnr TEXT, -- 授权内容(JSON)
|
||
gqsj DATETIME, -- 过期时间
|
||
gllp VARCHAR(512) -- 关联令牌(第三方token)
|
||
);
|
||
```
|
||
|
||
#### txw_sso_refresh_token(刷新令牌表)
|
||
```sql
|
||
CREATE TABLE txw_sso_refresh_token (
|
||
uuid VARCHAR(64) PRIMARY KEY,
|
||
yh_uuid VARCHAR(64),
|
||
refresh_token VARCHAR(512),
|
||
clientid VARCHAR(64),
|
||
sqnr TEXT,
|
||
gqsj DATETIME -- 过期时间(通常30天)
|
||
);
|
||
```
|
||
|
||
#### txw_sso_code(授权码表)
|
||
```sql
|
||
CREATE TABLE txw_sso_code (
|
||
uuid VARCHAR(64) PRIMARY KEY,
|
||
yh_uuid VARCHAR(64),
|
||
sqm VARCHAR(128), -- 授权码
|
||
clientid VARCHAR(64),
|
||
sqnr TEXT,
|
||
gqsj DATETIME, -- 过期时间(通常10分钟)
|
||
cdxdz VARCHAR(512), -- 重定向地址
|
||
rzzt VARCHAR(2), -- 认证状态
|
||
access_token VARCHAR(512)
|
||
);
|
||
```
|
||
|
||
#### txw_sso_client(OAuth2客户端表)
|
||
```sql
|
||
CREATE TABLE txw_sso_client (
|
||
uuid VARCHAR(64) PRIMARY KEY,
|
||
clientid VARCHAR(64), -- 客户端ID
|
||
sqmy VARCHAR(256), -- 授权密钥(secret)
|
||
yxbz VARCHAR(2), -- 有效标志(Y/N)
|
||
fwlpyxq VARCHAR(32), -- 访问令牌有效期(秒)
|
||
sxlpyxq VARCHAR(32), -- 刷新令牌有效期(秒)
|
||
cdxdz VARCHAR(512), -- 重定向地址
|
||
sqnr TEXT, -- 授权范围
|
||
dcdz VARCHAR(512) -- 登出地址
|
||
);
|
||
```
|
||
|
||
### 2.2 用户信息存储
|
||
|
||
**注意:** 用户信息(账号、密码、手机号等)**不存储在 SSO 服务**,而是存储在 **txw-mhzc(碳门户)服务**中,通过 API 调用获取。
|
||
|
||
用户信息表结构在 mhzc 服务中定义(YhxxbDTO)。
|
||
|
||
---
|
||
|
||
## 三、登录流程详解
|
||
|
||
### 3.1 账号密码登录
|
||
|
||
```
|
||
用户输入(用户名+密码+图形验证码)
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 校验图形验证码 │
|
||
│ verifyService.checkCaptcha(uuid, code) │
|
||
│ 验证码错误 → 返回错误 │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 验证通过
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 校验账号 │
|
||
│ yhxxService.getYhxxByDlzh(username) │
|
||
│ 用户不存在 → 返回"用户名或密码错误" │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 用户存在
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 检查账户锁定状态 │
|
||
│ accountLockService.checkLockStatus(yhUuid) │
|
||
│ 已锁定 → 返回"密码错误次数过多,账户已锁定" │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 未锁定
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 校验密码 │
|
||
│ MD5(rawPassword) == dlmm │
|
||
│ 密码错误 → 记录错误次数 → 达到阈值则锁定 │
|
||
│ → 返回"用户名或密码错误" │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 密码正确
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 清除密码错误次数缓存 │
|
||
│ accountLockService.clearCache(yhUuid) │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 创建访问令牌 │
|
||
│ oauth2TokenService.createAccessToken() │
|
||
│ → 生成 access_token + refresh_token │
|
||
│ → 存储到数据库 + Redis │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 返回登录凭证 │
|
||
│ { accessToken, refreshToken, expiresTime } │
|
||
│ 同时写入 HttpOnly Cookie │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**API 路径:** `POST /sso/auth/login`
|
||
|
||
**请求参数:**
|
||
```json
|
||
{
|
||
"username": "string", // 登录账号
|
||
"password": "string", // 密码(MD5)
|
||
"captchaVerification": "uuid", // 图形验证码UUID
|
||
"captchaCode": "string" // 图形验证码内容
|
||
}
|
||
```
|
||
|
||
### 3.2 短信验证码登录
|
||
|
||
```
|
||
用户输入(手机号+短信验证码+图形验证码)
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 校验图形验证码 │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 检查短信验证码是否正确 │
|
||
│ Redis: sms_token:{phone+sms} │
|
||
│ 不存在或已过期 → 返回"验证码错误" │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 验证码正确
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 根据手机号查询用户 │
|
||
│ yhxxService.getYhxxBySjhm(phone) │
|
||
│ 用户不存在 → 返回"该手机号未注册" │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 用户存在
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 创建访问令牌(同账号密码登录) │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**API 路径:** `POST /sso/auth/loginBySMS`
|
||
|
||
**请求参数:**
|
||
```json
|
||
{
|
||
"sjhm": "string", // 手机号
|
||
"sms": "string", // 短信验证码
|
||
"captchaVerification": "uuid", // 图形验证码UUID
|
||
"captchaCode": "string" // 图形验证码内容
|
||
}
|
||
```
|
||
|
||
### 3.3 DID 登录(去中心化身份)
|
||
|
||
**概述:** DID登录是基于区块链的去中心化身份认证,用户通过DID APP扫描二维码获取可验证凭证(Verifiable Presentation),完成身份认证。
|
||
|
||
**关键概念:**
|
||
- **DID(Decentralized Identifier)**:去中心化身份标识符
|
||
- **VP(Verifiable Presentation)**:可验证展示,包含用户身份证明
|
||
- **VC(Verifiable Credential)**:可验证凭证,由可信签发者签发
|
||
|
||
**完整流程(时序图):**
|
||
|
||
```
|
||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||
│ 用户 │ │ 前端 │ │ SSO服务 │ │DID服务 │
|
||
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
|
||
│ │ │ │
|
||
│ 1.点击登录 │ │ │
|
||
│──────────────────>│ │ │
|
||
│ │ │ │
|
||
│ 2.请求二维码 │ │ │
|
||
│ │POST /did/pub/login/qrcode │
|
||
│ │───────────────────────────────────────>
|
||
│ │ │ │
|
||
│ │ │ 3.生成reqId │
|
||
│ │ │ 4.缓存状态=PAGE │
|
||
│ │ │ │
|
||
│ │ 5.返回{reqId, url}│ │
|
||
│ │<───────────────────────────────────────│
|
||
│ │ │ │
|
||
│ 6.显示二维码 │ │ │
|
||
│<───────────────────│ │ │
|
||
│ │ │ │
|
||
│ 7.扫描二维码 │ │ │
|
||
│───────────────────────────────────────────────────────────│
|
||
│ │ │ │
|
||
│ │ │ 8.用户在DID APP │
|
||
│ │ │ 确认授权 │
|
||
│ │ │ │
|
||
│ 9.VP返回 │ │ │
|
||
│<──────────────────────────────────────────────────────────│
|
||
│ │ │ │
|
||
│ 10.提交VP验证 │ │ │
|
||
│ │POST /did/pub/getvp/login?reqId=xxx │
|
||
│ │───────────────────────────────────────>
|
||
│ │ │ │
|
||
│ │ │ 11.验证VP │
|
||
│ │ │ 12.更新状态=QR │
|
||
│ │ │ │
|
||
│ │ │ 13.DID回调 │
|
||
│ │ │POST /did/pub/callback/login
|
||
│ │ │<──────────────────────────────────────────────────│
|
||
│ │ │ │
|
||
│ │ │ 14.验证VP签名 │
|
||
│ │ │ │
|
||
│ │ │ 15.提取身份信息 │
|
||
│ │ │ (手机号/DID) │
|
||
│ │ │ │
|
||
│ │ │ 16.查询/创建用户 │
|
||
│ │ │ 绑定DID │
|
||
│ │ │ │
|
||
│ │ │ 17.更新状态=CALLBACK│
|
||
│ │ │ │
|
||
│ │ 18.轮询结果 │ │
|
||
│ │GET /did/pub/backresult/login?reqId=xxx│
|
||
│ │───────────────────────────────────────>
|
||
│ │ │ │
|
||
│ │ │ 19.检查状态 │
|
||
│ │ │ 20.返回用户信息 │
|
||
│ │ 21.返回authLoginRespVO │
|
||
│ │<───────────────────────────────────────│
|
||
│ │ │ │
|
||
│ 22.登录成功 │ │ │
|
||
│<───────────────────│ │ │
|
||
```
|
||
|
||
**DID登录状态机:**
|
||
|
||
| 状态 | 值 | 说明 |
|
||
|------|---|------|
|
||
| PAGE | 1 | 初始状态,等待扫码 |
|
||
| QR | 2 | 已扫码,等待授权 |
|
||
| CALLBACK | 3 | 已授权,流程完成 |
|
||
| PHONE_BIND | 4 | 需要手机号绑定(手机号为空时) |
|
||
| BUSI_FAILURE | 5 | 认证失败 |
|
||
| BUSI_SUCCESS | 6 | 认证成功 |
|
||
|
||
**伪代码:**
|
||
|
||
```java
|
||
// ========== DID Controller ==========
|
||
|
||
/**
|
||
* 1. 获取登录二维码
|
||
* 流程:请求DID服务端获取二维码URL,生成reqId并缓存状态
|
||
*/
|
||
@PostMapping("/pub/login/qrcode")
|
||
public CommonResult<QrCodeInfo> getLoginUrlQRCode() {
|
||
// 1. 从系统参数获取DID登录VP请求地址
|
||
String vpUrl = XtcsUtils.getXtcs("TXW_DID_LOGIN_VP_URL");
|
||
|
||
// 2. 生成唯一请求标识
|
||
String reqId = UUID.randomUUID().toString() + UUID.randomUUID().toString();
|
||
|
||
// 3. 构建完整URL
|
||
String qrCodeUrl = vpUrl + "?reqId=" + reqId;
|
||
|
||
// 4. 缓存请求状态(30分钟过期)
|
||
CacheUtils.cacheData("TXW:DID:REQ:ID:" + reqId, "1", 30*60*1000);
|
||
|
||
// 5. 返回二维码信息
|
||
return CommonResult.success(QrCodeInfo.builder()
|
||
.reqId(reqId)
|
||
.url(qrCodeUrl)
|
||
.build());
|
||
}
|
||
|
||
/**
|
||
* 2. 获取登录凭证(VP)
|
||
* 流程:验证状态,调用DID SDK构建VP请求
|
||
*/
|
||
@GetMapping("/pub/getvp/login")
|
||
public String getLoginVP(@RequestParam String reqId) {
|
||
// 1. 验证请求状态
|
||
String status = CacheUtils.getCacheData("TXW:DID:REQ:ID:" + reqId);
|
||
if (!"1".equals(status)) {
|
||
return jsonError("请求状态不正确,请重新操作");
|
||
}
|
||
|
||
// 2. 更新状态为已扫码
|
||
CacheUtils.cacheData("TXW:DID:REQ:ID:" + reqId, "2");
|
||
|
||
// 3. 创建DID客户端
|
||
IDidClient didClient = new DidClient(
|
||
XtcsUtils.getXtcs("TXW_DID_CONSOLE_LOGIN_URL"),
|
||
DidClient.version15
|
||
);
|
||
|
||
// 4. DID控制台登录
|
||
Response<AccessToken> login = didClient.login(
|
||
XtcsUtils.getXtcs("TXW_DID_CONSOLE_USERNAME"),
|
||
XtcsUtils.getXtcs("TXW_DID_CONSOLE_PASSWORD")
|
||
);
|
||
|
||
// 5. 构建VP请求扩展信息
|
||
LoginExtend extend = LoginExtend.builder()
|
||
.requestId(reqId)
|
||
.method("POST")
|
||
.callbackUrl(callbackUrl + "?reqId=" + reqId)
|
||
.authorizedName("碳信网")
|
||
.authorizedNameEn("TanxinWeb")
|
||
.build();
|
||
|
||
// 6. 添加需要获取的凭证类型
|
||
List<DidExtendVcsInfo> vcs = new ArrayList<>();
|
||
vcs.add(DidExtendVcsInfo.builder()
|
||
.vctId("100001") // 个人身份凭证
|
||
.vctVersion("v9")
|
||
.issuer("did:cndid:cndid")
|
||
.build());
|
||
extend.setVcs(vcs);
|
||
|
||
// 7. 构建可验证展示
|
||
VerifiablePresentation<LoginExtend> loginvp = VerifiablePresentation.builder()
|
||
.presentationUsage("DID_LOGIN_REQUEST")
|
||
.proof(proofList)
|
||
.extend(extend)
|
||
.build();
|
||
|
||
// 8. 生成并签名VP
|
||
Response<VerifiablePresentation<LoginExtend>> res =
|
||
didClient.vpSign(loginvp, LoginExtend.class);
|
||
|
||
// 9. 返回VP JSON
|
||
return JsonUtils.toJson(res);
|
||
}
|
||
|
||
/**
|
||
* 3. 登录回调处理
|
||
* 流程:DID服务端回调,验证VP,创建/绑定用户
|
||
*/
|
||
@PostMapping("/pub/callback/login")
|
||
public String getLoginVPCallback(@RequestParam String reqId,
|
||
@RequestBody Map<String, Object> vpRequestBody) {
|
||
// 1. 解析VP
|
||
VerifiablePresentation request = BeanUtils.toBean(vpRequestBody,
|
||
VerifiablePresentation.class);
|
||
|
||
// 2. 验证VP签名
|
||
String checkMsg = checkvp(request);
|
||
if (checkMsg != null) {
|
||
return jsonError(checkMsg);
|
||
}
|
||
|
||
// 3. 更新状态为已回调
|
||
CacheUtils.cacheData("TXW:DID:REQ:ID:" + reqId, "3");
|
||
|
||
// 4. 提取凭证信息
|
||
List<VerifiableCredential> vcList = request.getVerifiableCredential();
|
||
if (vcList == null || vcList.isEmpty()) {
|
||
return jsonError("凭证为空");
|
||
}
|
||
|
||
VerifiableCredential vc = vcList.get(0);
|
||
String holderDid = vc.getHolder(); // 获取DID标识
|
||
|
||
// 5. 提取身份信息
|
||
DidCredentialSubject cs = BeanUtils.toBean(vc.getCredentialSubject(),
|
||
DidCredentialSubject.class);
|
||
String phone = cs.getPhone(); // 手机号
|
||
String legalName = cs.getLegalName(); // 法人姓名
|
||
String entName = cs.getEntname(); // 企业名称
|
||
|
||
// 6. 根据不同条件查找/创建用户
|
||
YhxxbDTO yhxx = null;
|
||
if (StringUtils.isNotBlank(phone)) {
|
||
// 6.1 根据手机号查找
|
||
yhxx = yhxxService.getYhxxBySjhm(phone);
|
||
} else if (idCard != null) {
|
||
// 6.2 根据身份证查找
|
||
yhxx = yhxxService.getYhxxBySfzjhm(idCard);
|
||
} else {
|
||
// 6.3 根据DID查找
|
||
yhxx = yhxxService.getYhxxByDid(holderDid);
|
||
}
|
||
|
||
if (yhxx == null && StringUtils.isBlank(phone)) {
|
||
// 6.4 手机号为空,需要后续绑定
|
||
CacheUtils.cacheData("TXW:DID:USER:DID:" + reqId, holderDid, 30*60*1000);
|
||
CacheUtils.cacheData("TXW:DID:USER:DID:Data:" + reqId,
|
||
JsonUtils.toJson(cs), 30*60*1000);
|
||
CacheUtils.cacheData("TXW:DID:REQ:ID:" + reqId, "4"); // PHONE_BIND
|
||
return jsonSuccess("需要手机绑定");
|
||
}
|
||
|
||
if (yhxx == null && StringUtils.isNotBlank(phone)) {
|
||
// 6.5 新用户,创建用户
|
||
yhxx = new YhxxbDTO();
|
||
yhxx.setYhUuid(IdUtil.fastSimpleUUID());
|
||
yhxx.setDid(holderDid);
|
||
yhxx.setSjhm1(phone);
|
||
yhxx.setDlzh(phone);
|
||
yhxx.setZsxm1(legalName != null ? legalName : entName);
|
||
yhxx = yhxxService.saveYhxxByDid(yhxx);
|
||
} else {
|
||
// 6.6 已有用户,绑定DID
|
||
yhxx.setDid(holderDid);
|
||
yhxxService.updateDid(yhxx);
|
||
}
|
||
|
||
// 7. 创建企业信息(DID企业认证)
|
||
if (StringUtils.isNotBlank(entName)) {
|
||
YhxxbDTO qyxx = new YhxxbDTO();
|
||
qyxx.setQymc(entName);
|
||
qyxx.setNsrsbh(cs.getUniscid()); // 统一社会信用代码
|
||
qyxx.setYhUuid(yhxx.getYhUuid());
|
||
yhxxService.intQyxxByDid(qyxx);
|
||
}
|
||
|
||
// 8. 缓存用户信息
|
||
CacheUtils.cacheData("TXW:DID:USER:Data:" + reqId,
|
||
JsonUtils.toJson(yhxx), 30*60*1000);
|
||
|
||
return jsonSuccess("true");
|
||
}
|
||
|
||
/**
|
||
* 4. 查询登录结果(轮询)
|
||
* 流程:前端轮询检查状态,返回accessToken
|
||
*/
|
||
@GetMapping("/pub/backresult/login")
|
||
public CommonResult<R> getLoginCallbackResult(@RequestParam String reqId) {
|
||
// 1. 获取状态
|
||
String status = CacheUtils.getCacheData("TXW:DID:REQ:ID:" + reqId);
|
||
|
||
if ("6".equals(status)) { // 认证成功
|
||
// 2. 获取用户信息
|
||
String userJson = CacheUtils.getCacheData("TXW:DID:USER:Data:" + reqId);
|
||
YhxxbDTO yhxx = JsonUtils.toBean(userJson, YhxxbDTO.class);
|
||
|
||
// 3. 创建Token
|
||
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(
|
||
yhxx.getYhUuid(), "default", null);
|
||
|
||
// 4. 返回登录凭证
|
||
AuthLoginRespVO respVO = new AuthLoginRespVO();
|
||
respVO.setAccessToken(accessTokenDO.getAccessToken());
|
||
respVO.setRefreshToken(accessTokenDO.getRefreshToken());
|
||
respVO.setExpiresTime(accessTokenDO.getGqsj());
|
||
|
||
// 5. 写入Cookie
|
||
Cookie cookie = new Cookie("TXW_TOKEN", accessTokenDO.getAccessToken());
|
||
cookie.setPath("/");
|
||
cookie.setHttpOnly(true);
|
||
response.addCookie(cookie);
|
||
|
||
return CommonResult.success(R.ok().put("status", status)
|
||
.put("authLoginRespVO", respVO));
|
||
} else if ("5".equals(status)) { // 认证失败
|
||
String msg = CacheUtils.getCacheData("TXW:DID:BUSI:FAILURE:MSG:" + reqId);
|
||
return CommonResult.success(R.ok().put("status", status).put("msg", msg));
|
||
}
|
||
|
||
// 继续轮询
|
||
return CommonResult.success(R.ok().put("status", status));
|
||
}
|
||
|
||
/**
|
||
* 5. DID绑定手机号(DID用户关联手机号)
|
||
*/
|
||
@PostMapping("/auth/didBindPhone")
|
||
public CommonResult<AuthLoginRespVO> didBindPhone(@RequestBody DidBindPhoneReqVO reqVO) {
|
||
// 1. 验证短信验证码
|
||
String sms = reqVO.getSms();
|
||
String phone = reqVO.getSjhm();
|
||
if (!stringRedisTemplate.hasKey("sms_token:" + phone + sms)) {
|
||
throw exception("验证码错误");
|
||
}
|
||
|
||
// 2. 获取缓存的DID信息
|
||
String reqId = reqVO.getReqId();
|
||
String holderDid = CacheUtils.getCacheData("TXW:DID:USER:DID:" + reqId);
|
||
String csJson = CacheUtils.getCacheData("TXW:DID:USER:DID:Data:" + reqId);
|
||
|
||
if (holderDid == null || csJson == null) {
|
||
throw exception("缓存DID为空");
|
||
}
|
||
|
||
// 3. 创建用户
|
||
DidCredentialSubject cs = JsonUtils.toBean(csJson, DidCredentialSubject.class);
|
||
YhxxbDTO existYhxx = yhxxService.getYhxxBySjhm(phone);
|
||
|
||
if (existYhxx != null) {
|
||
throw exception("该手机号已注册");
|
||
}
|
||
|
||
YhxxbDTO yhxx = new YhxxbDTO();
|
||
yhxx.setYhUuid(IdUtil.fastSimpleUUID());
|
||
yhxx.setDid(holderDid);
|
||
yhxx.setSjhm1(phone);
|
||
yhxx.setDlzh(phone);
|
||
yhxx.setZsxm1(cs.getLegalName() != null ? cs.getLegalName() : cs.getEntname());
|
||
yhxx = yhxxService.saveYhxxByDid(yhxx);
|
||
|
||
// 4. 创建企业信息
|
||
YhxxbDTO qyxx = new YhxxbDTO();
|
||
qyxx.setQymc(cs.getEntname());
|
||
qyxx.setNsrsbh(cs.getUniscid());
|
||
qyxx.setYhUuid(yhxx.getYhUuid());
|
||
yhxxService.intQyxxByDid(qyxx);
|
||
|
||
// 5. 创建Token并返回
|
||
return createTokenAfterLoginSuccess(yhxx.getYhUuid());
|
||
}
|
||
```
|
||
|
||
**DID SDK 调用伪代码:**
|
||
|
||
```java
|
||
// ========== DID SDK 使用示例 ==========
|
||
|
||
/**
|
||
* DID客户端初始化
|
||
*/
|
||
IDidClient didClient = new DidClient(
|
||
consoleUrl, // DID控制台地址
|
||
DidClient.version15 // API版本
|
||
);
|
||
|
||
/**
|
||
* 1. 控制台登录
|
||
*/
|
||
Response<AccessToken> loginResponse = didClient.login(
|
||
username, // 用户名
|
||
password // 密码
|
||
);
|
||
// 返回: { code: 200000, data: { accessToken: "xxx", expiresIn: 3600 } }
|
||
|
||
/**
|
||
* 2. 构建登录VP请求
|
||
*/
|
||
LoginExtend extend = LoginExtend.builder()
|
||
.requestId(reqId) // 请求唯一标识
|
||
.method("POST") // 回调方法
|
||
.callbackUrl(callbackUrl) // 回调地址
|
||
.authorizedName("碳信网") // 授权平台名称
|
||
.authorizedNameEn("TanxinWeb")
|
||
.build();
|
||
|
||
List<Proof> proofList = new ArrayList<>();
|
||
|
||
VerifiablePresentationBuilder<LoginExtend> builder =
|
||
VerifiablePresentation.builder();
|
||
builder.presentationUsage("DID_LOGIN_REQUEST");
|
||
builder.proof(proofList);
|
||
builder.extend(extend);
|
||
|
||
VerifiablePresentation<LoginExtend> loginvp = builder.build();
|
||
|
||
/**
|
||
* 3. 生成并签名VP
|
||
*/
|
||
Response<VerifiablePresentation<LoginExtend>> signResponse =
|
||
didClient.vpSign(loginvp, LoginExtend.class);
|
||
// 返回签名后的VP JSON
|
||
|
||
/**
|
||
* 4. 验证VP
|
||
*/
|
||
Response<Boolean> verifyResponse = didClient.vpVerify(vpJson);
|
||
// 返回: { code: 200000, data: true/false }
|
||
|
||
/**
|
||
* 5. 提取凭证信息
|
||
*/
|
||
VerifiableCredential vc = vp.getVerifiableCredential().get(0);
|
||
String holderDid = vc.getHolder(); // DID标识符
|
||
DidCredentialSubject subject = vc.getCredentialSubject();
|
||
// subject.getPhone() - 手机号
|
||
// subject.getLegalName() - 法人姓名
|
||
// subject.getEntname() - 企业名称
|
||
// subject.getUniscid() - 统一社会信用代码
|
||
```
|
||
|
||
**VP数据结构示例:**
|
||
|
||
```json
|
||
{
|
||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||
"type": ["VerifiablePresentation"],
|
||
"holder": "did:cndid:xxxxxx",
|
||
"verifiableCredential": [{
|
||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||
"type": ["VerifiableCredential"],
|
||
"credentialSubject": {
|
||
"id": "did:cndid:xxxxxx",
|
||
"phone": "13800138000",
|
||
"legalName": "张三",
|
||
"entname": "示例企业",
|
||
"uniscid": "91110000xxxxxxxxxx"
|
||
},
|
||
"proof": {
|
||
"type": "EcdsaSecp256k1Signature2019",
|
||
"creator": "did:cndid:issuer",
|
||
"created": "2025-01-01T00:00:00Z"
|
||
}
|
||
}],
|
||
"proof": {
|
||
"type": "EcdsaSecp256k1Signature2019",
|
||
"created": "2025-01-01T00:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
**前端轮询实现伪代码:**
|
||
|
||
```javascript
|
||
/**
|
||
* DID登录轮询
|
||
*/
|
||
async pollLoginResult() {
|
||
while (this.isPolling) {
|
||
try {
|
||
// 1. 查询登录结果
|
||
const { data } = await rzbackresultlogin(this.reqId);
|
||
|
||
// 2. 根据状态处理
|
||
switch (data.status) {
|
||
case 6: // 认证成功
|
||
this.stopPolling();
|
||
await this.yhinit(); // 刷新用户信息
|
||
break;
|
||
|
||
case 5: // 认证失败
|
||
this.stopPolling();
|
||
MessagePlugin.info({ content: data.msg });
|
||
break;
|
||
|
||
case 4: // 需要手机绑定
|
||
// 显示手机绑定界面
|
||
this.showPhoneBind = true;
|
||
this.stopPolling();
|
||
break;
|
||
|
||
default: // 继续轮询
|
||
// 2秒后继续
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error('查询失败', error);
|
||
// 1.5秒后重试
|
||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成二维码
|
||
*/
|
||
generateQRCode() {
|
||
new QRCode(this.$refs.qrcodeElement, {
|
||
text: this.qrcodeText,
|
||
width: 200,
|
||
height: 200,
|
||
colorDark: '#000000',
|
||
colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.H
|
||
});
|
||
}
|
||
```
|
||
|
||
### 3.4 OAuth2 授权码模式
|
||
|
||
```
|
||
用户访问第三方应用
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 第三方应用重定向到授权页面 │
|
||
│ GET /oauth2/authorize?clientId=xxx&redirectUri= │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 用户在授权页面点击授权 │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 生成授权码 │
|
||
│ oauth2CodeService.createCode() │
|
||
│ → 存储到数据库(10分钟过期) │
|
||
│ → 重定向到第三方应用redirectUri?code=xxx │
|
||
└─────────────────────────────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 第三方应用用授权码换取令牌 │
|
||
│ POST /oauth2/token/create │
|
||
│ → 验证clientId + clientSecret │
|
||
│ → 验证授权码 │
|
||
│ → 生成 accessToken + refreshToken │
|
||
│ → 返回令牌 │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 四、Token 创建与刷新机制
|
||
|
||
### 4.1 创建访问令牌
|
||
|
||
```java
|
||
// OAuth2TokenServiceImpl.createAccessToken()
|
||
1. 验证客户端配置 (clientId)
|
||
2. 创建 SessionInfo
|
||
3. 创建 refreshToken (如果非default客户端)
|
||
4. 创建 accessToken
|
||
5. 存储到数据库
|
||
6. 缓存到 Redis
|
||
7. 返回令牌信息
|
||
```
|
||
|
||
**Redis Key 格式:**
|
||
```
|
||
oauth2_access_token:{accessToken} → SessionInfo JSON
|
||
```
|
||
|
||
### 4.2 刷新令牌
|
||
|
||
```
|
||
用户使用 refreshToken 请求刷新
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 验证 refreshToken 是否有效 │
|
||
│ → 检查是否过期 │
|
||
│ → 检查 clientId 是否匹配 │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 删除旧的 accessToken │
|
||
│ → 从数据库删除 │
|
||
│ → 从 Redis 删除 │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 生成新的 accessToken │
|
||
│ → 沿用原来的 refreshToken │
|
||
│ → 更新过期时间 │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**API 路径:** `PUT /oauth2/token/refresh?refreshToken=xxx&clientId=xxx`
|
||
|
||
---
|
||
|
||
## 五、验证码服务
|
||
|
||
### 5.1 图形验证码
|
||
|
||
```
|
||
请求获取验证码
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 生成验证码 UUID + 图片 │
|
||
│ verifyService.getCaptcha(remoteId) │
|
||
│ → UUID 存入 Redis (5分钟过期) │
|
||
│ → 返回 { uuid, 图片Base64 } │
|
||
└─────────────────────────────────────────────────┘
|
||
|
||
校验验证码
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ verifyService.checkCaptcha(uuid, code) │
|
||
│ → 从 Redis 获取 UUID 对应的验证码 │
|
||
│ → 比较用户输入与存储的验证码(忽略大小写) │
|
||
│ → 验证后删除 Redis 记录 │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 5.2 短信验证码
|
||
|
||
```
|
||
发送短信请求
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 校验图形验证码 │
|
||
└─────────────────────┬───────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 检查手机号是否被锁定 │
|
||
│ Redis: sms_token:{phone} 存在则锁定 │
|
||
│ 锁定中 → 返回"操作过于频繁" │
|
||
└─────────────────────┬───────────────────────────┘
|
||
│ 未锁定
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 发送短信 │
|
||
│ smService.sendCaptcha(phone, code) │
|
||
│ → 调用外部短信网关(阿里云) │
|
||
│ → 验证码存入 Redis: sms_token:{phone+code} │
|
||
│ 有效期3分钟 │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Redis Key 规则:**
|
||
```
|
||
sms_token:{phone} → 锁定标记(1分钟)
|
||
sms_token:{phone+code} → 验证码(3分钟)
|
||
```
|
||
|
||
---
|
||
|
||
## 六、外部服务依赖
|
||
|
||
### 6.1 依赖关系图
|
||
|
||
```
|
||
┌──────────────────┐
|
||
│ 外部服务 │
|
||
└───────┬──────────┘
|
||
│
|
||
┌───────────────────┼───────────────────┐
|
||
▼ ▼ ▼
|
||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||
│ 短信网关 │ │ DID服务 │ │ mhzc服务 │
|
||
│ (阿里云) │ │ (ChainWeaver)│ │ (用户中心) │
|
||
└──────────────┘ └──────────────┘ └──────────────┘
|
||
│ │ │
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌──────────────────────────────────────────────────┐
|
||
│ txw-sso 服务 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
|
||
│ │ AuthService │ │ DidController│ │YhxxService │ │
|
||
│ └─────────────┘ └─────────────┘ └────────────┘ │
|
||
└──────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 6.2 外部服务清单
|
||
|
||
| 服务 | 依赖方式 | 用途 |
|
||
|------|---------|------|
|
||
| **短信网关(阿里云)** | REST API | 发送短信验证码 |
|
||
| **DID服务** | SDK (org.chainweaver.did.sdk) | DID登录、企业实名认证 |
|
||
| **txw-mhzc** | Feign RPC | 用户信息查询、用户注册 |
|
||
|
||
### 6.3 短信网关配置
|
||
|
||
```java
|
||
// SMSConfig 配置
|
||
sms.host → 短信API地址
|
||
sms.app → 应用标识
|
||
sms.key → 密钥(用于签名校验)
|
||
sms.template → 短信模板(验证码内容格式)
|
||
```
|
||
|
||
**签名校验算法:**
|
||
```java
|
||
vcode = MD5(MD5(timestamp + app + token + key))
|
||
```
|
||
|
||
### 6.4 DID 服务配置
|
||
|
||
| 配置项 | 说明 |
|
||
|--------|------|
|
||
| `TXW_DID_LOGIN_VP_URL` | 登录凭证请求地址 |
|
||
| `TXW_DID_BUSI_LICE_VP_URL` | 企业实名凭证请求地址 |
|
||
| `TXW_DID_LOGIN_CALLBACK_URL` | 登录回调地址 |
|
||
| `TXW_DID_BUSI_LICE_CALLBACK_URL` | 企业实名回调地址 |
|
||
| `TXW_DID_CONSOLE_LOGIN_URL` | DID控制台登录地址 |
|
||
| `TXW_DID_CONSOLE_USERNAME` | DID控制台用户名 |
|
||
| `TXW_DID_CONSOLE_PASSWORD` | DID控制台密码 |
|
||
| `TXW_DID_CONSOLE_DIDCODE` | DID标识 |
|
||
| `TXW_DID_AUTH_NAME` | 授权平台名称 |
|
||
| `TXW_DID_AUTH_EN_NAME` | 授权平台英文名称 |
|
||
|
||
---
|
||
|
||
## 七、Redis 缓存设计
|
||
|
||
### 7.1 Key 规则汇总
|
||
|
||
| Key Pattern | 值类型 | 过期时间 | 用途 |
|
||
|------------|-------|---------|------|
|
||
| `oauth2_access_token:%s` | SessionInfo JSON | 动态(令牌过期时间) | 访问令牌缓存 |
|
||
| `sms_token:%s` | phone | 1分钟 | 手机号锁定 |
|
||
| `sms_token:%s%s` | phone | 3分钟 | 短信验证码 |
|
||
| `TXW:DID:REQ:ID:%s` | status | 30分钟 | DID请求状态 |
|
||
| `TXW:DID:USER:DID:%s` | did | 30分钟 | DID用户绑定 |
|
||
| `TXW:DID:USER:DID:Data:%s` | DidCredentialSubject JSON | 30分钟 | DID用户数据 |
|
||
| `TXW:DID:USER:Data:%s` | YhxxbDTO JSON | 30分钟 | DID登录用户信息 |
|
||
| `TXW:DID:BUSI:FAILURE:MSG:%s` | msg | 30分钟 | DID认证失败消息 |
|
||
|
||
### 7.2 账户锁定 Key
|
||
|
||
| Key Pattern | 说明 |
|
||
|------------|------|
|
||
| `account_lock:%s` | 账户锁定标记 |
|
||
|
||
**锁定规则:** 密码连续错误5次,锁定30分钟
|
||
|
||
---
|
||
|
||
## 八、接口清单
|
||
|
||
### 8.1 认证接口 (/auth)
|
||
|
||
| 接口 | 方法 | 说明 |
|
||
|------|------|------|
|
||
| `/auth/login` | POST | 账号密码登录 |
|
||
| `/auth/logout` | POST | 登出 |
|
||
| `/auth/refresh-token` | POST | 刷新令牌 |
|
||
| `/auth/sendMsg` | POST | 发送短信验证码 |
|
||
| `/auth/loginBySMS` | POST | 短信验证码登录 |
|
||
| `/auth/didBindPhone` | POST | DID绑定手机号 |
|
||
| `/auth/changePassword` | POST | 修改密码 |
|
||
| `/auth/resetPassword` | POST | 重置密码(管理员) |
|
||
|
||
### 8.2 OAuth2接口 (/oauth2)
|
||
|
||
| 接口 | 方法 | 说明 |
|
||
|------|------|------|
|
||
| `/oauth2/token/create` | POST | 创建令牌 |
|
||
| `/oauth2/token/check` | GET | 校验令牌 |
|
||
| `/oauth2/token/remove` | DELETE | 删除令牌 |
|
||
| `/oauth2/token/refresh` | PUT | 刷新令牌 |
|
||
| `/oauth2/authorize` | GET | 授权码模式授权 |
|
||
| `/oauth2/authorize` | POST | 授权确认 |
|
||
|
||
### 8.3 验证码接口 (/verify)
|
||
|
||
| 接口 | 方法 | 说明 |
|
||
|------|------|------|
|
||
| `/verify/get` | POST | 获取滑动验证码 |
|
||
| `/verify/captcha` | POST | 获取图形验证码 |
|
||
| `/verify/checkCaptcha` | POST | 校验图形验证码 |
|
||
|
||
### 8.4 DID接口 (/did)
|
||
|
||
| 接口 | 方法 | 说明 |
|
||
|------|------|------|
|
||
| `/did/pub/login/qrcode` | POST | 获取登录二维码 |
|
||
| `/did/pub/getvp/login` | GET | 获取登录凭证 |
|
||
| `/did/pub/callback/login` | POST | 登录回调 |
|
||
| `/did/pub/backresult/login` | GET | 查询登录结果 |
|
||
| `/did/busilice/qrcode` | POST | 获取认证二维码 |
|
||
| `/did/pub/getvp/busi` | GET | 获取企业实名凭证 |
|
||
| `/did/pub/callback/busi` | POST | 企业实名回调 |
|
||
| `/did/pub/backresult/busi` | GET | 查询认证结果 |
|
||
|
||
---
|
||
|
||
## 九、安全机制
|
||
|
||
### 9.1 密码安全
|
||
|
||
- 密码使用 **MD5** 存储(实际应为更安全的哈希算法)
|
||
- 支持密码复杂度校验(6-20位,字母+数字)
|
||
- 新旧密码不能相同
|
||
|
||
### 9.2 防暴力破解
|
||
|
||
- 密码连续错误5次,锁定账户30分钟
|
||
- 短信验证码错误3次,锁定手机号1分钟
|
||
- 验证码一次性使用,验证后立即失效
|
||
|
||
### 9.3 Token安全
|
||
|
||
- AccessToken 默认有效期:2小时(从客户端配置读取)
|
||
- RefreshToken 默认有效期:30天
|
||
- Token 支持客户端关联,支持第三方平台SSO
|
||
|
||
### 9.4 Cookie安全
|
||
|
||
```java
|
||
Cookie cookie = new Cookie(COOKIE_TOKEN_KEY, token);
|
||
cookie.setPath("/");
|
||
cookie.setHttpOnly(true); // 防止XSS读取
|
||
// cookie.setMaxAge(-1); // 浏览器会话级别
|
||
```
|
||
|
||
---
|
||
|
||
## 十、错误码
|
||
|
||
| 错误码 | 说明 |
|
||
|--------|------|
|
||
| `AUTH_LOGIN_BAD_CREDENTIALS` | 用户名或密码错误 |
|
||
| `AUTH_LOGIN_CAPTCHA_CODE_ERROR` | 验证码错误 |
|
||
| `AUTH_LOGIN_PASSWORD_ERROR_LOCK` | 密码错误次数过多,账户已锁定 |
|
||
| `AUTH_PASSWORD_CONFIRM_MISMATCH` | 新密码与确认密码不一致 |
|
||
| `AUTH_PASSWORD_COMPLEXITY_INVALID` | 密码复杂度不符合要求 |
|
||
| `AUTH_PASSWORD_SAME_AS_OLD` | 新密码不能与旧密码相同 |
|
||
| `OAUTH2_SJHM_LOCK` | 手机号操作过于频繁 |
|
||
| `OAUTH2_SJHM_NOT_EXISTS` | 手机号未注册 |
|
||
| `OAUTH2_LOGIN_SMS_NOT_EXISTS` | 短信验证码不存在或已过期 |
|
||
| `OAUTH2_LOGIN_SJHM_NOT_EXISTS` | 短信登录手机号不存在 |
|
||
|
||
---
|
||
|
||
*文档生成时间: 2026-05-03*
|
||
*基于 txw-sso 服务代码分析*
|