1553 lines
58 KiB
Markdown
1553 lines
58 KiB
Markdown
# 多方式登录与第三方登录实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-subagent-driven-development (recommended) or superpowers-executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 实现密码(支持用户名/手机号/邮箱)、短信、微信、支付宝四种登录方式,支持第三方账号绑定和 unionId 优先查询。
|
||
|
||
**Architecture:** 基于 OAuth2 自定义授权模式,每种登录方式独立实现 Converter + Provider,统一走 OAuth2 Token 生成流程。手机号作为主键,第三方登录自动创建用户。
|
||
|
||
**Tech Stack:** Java 21, Spring Boot 4.x, Spring Security OAuth2, Fastjson2, MyBatis Plus, Java HttpClient
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
### 数据库变更
|
||
- `sql/init-database.sql` - 新增 `rui_uc_user_social` 表,修改 `rui_uc_user` 和 `rui_uc_user_detail`
|
||
|
||
### rui-common-oauth2 模块(认证中心)
|
||
- `authentication/password/PasswordAuthenticationConverter.java` - 扩展支持 accountType
|
||
- `authentication/sms/SmsAuthenticationConverter.java` - 短信参数提取
|
||
- `authentication/sms/SmsAuthenticationProvider.java` - 短信认证逻辑
|
||
- `authentication/sms/SmsAuthenticationToken.java` - 短信认证令牌
|
||
- `authentication/weixin/WeixinAuthenticationConverter.java` - 微信参数提取
|
||
- `authentication/weixin/WeixinAuthenticationProvider.java` - 微信认证逻辑
|
||
- `authentication/weixin/WeixinAuthenticationToken.java` - 微信认证令牌
|
||
- `authentication/alipay/AlipayAuthenticationConverter.java` - 支付宝参数提取
|
||
- `authentication/alipay/AlipayAuthenticationProvider.java` - 支付宝认证逻辑
|
||
- `authentication/alipay/AlipayAuthenticationToken.java` - 支付宝认证令牌
|
||
- `config/OAuth2ServerConfig.java` - 注册新的 Converter 和 Provider
|
||
- `service/RemoteUserDetailsService.java` - 支持 EMAIL 类型
|
||
|
||
### rui-service-user 模块(用户服务)
|
||
- `entity/User.java` - 新增 email 字段
|
||
- `entity/UserDetail.java` - 删除 email 字段
|
||
- `entity/UserSocial.java` - 第三方账号关联实体
|
||
- `mapper/UserSocialMapper.java` - 第三方账号数据访问
|
||
- `service/IUserSocialService.java` - 第三方账号服务接口
|
||
- `service/impl/UserSocialServiceImpl.java` - 第三方账号服务实现
|
||
- `controller/inner/UserInnerController.java` - 支持 EMAIL 查询
|
||
- `dto/LoginAccountDTO.java` - 已有,无需修改
|
||
- `enums/AccountType.java` - 已有,无需修改
|
||
|
||
---
|
||
|
||
## Task 1: 数据库变更
|
||
|
||
**Files:**
|
||
- Modify: `sql/init-database.sql`
|
||
|
||
- [x] **Step 1: 在 `rui_uc_user` 表添加 email 字段**
|
||
|
||
在 `rui_uc_user` 表的 `phone` 字段后添加 `email` 字段:
|
||
|
||
```sql
|
||
CREATE TABLE rui_uc_user (
|
||
id BIGINT NOT NULL,
|
||
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID 0:系统级',
|
||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||
phone VARCHAR(20) DEFAULT NULL COMMENT '手机号',
|
||
email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', -- 新增
|
||
user_no VARCHAR(50) DEFAULT NULL COMMENT '用户编号(短编码,前端展示用)',
|
||
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)',
|
||
user_type TINYINT NOT NULL DEFAULT 1 COMMENT '用户类型 1:普通用户 2:管理员 3:系统用户',
|
||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 0:禁用 1:启用',
|
||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0:正常 1:已删',
|
||
created_by BIGINT DEFAULT NULL COMMENT '创建者ID',
|
||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
updated_by BIGINT DEFAULT NULL COMMENT '更新者ID',
|
||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uk_username (tenant_id, username),
|
||
UNIQUE KEY uk_phone (tenant_id, phone),
|
||
UNIQUE KEY uk_email (tenant_id, email), -- 新增
|
||
INDEX idx_tenant (tenant_id),
|
||
INDEX idx_status (status)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户';
|
||
```
|
||
|
||
- [x] **Step 2: 从 `rui_uc_user_detail` 表删除 email 字段**
|
||
|
||
修改 `rui_uc_user_detail` 表定义,删除 `email` 字段:
|
||
|
||
```sql
|
||
CREATE TABLE rui_uc_user_detail (
|
||
id BIGINT NOT NULL,
|
||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||
nickname VARCHAR(100) DEFAULT NULL COMMENT '昵称',
|
||
real_name VARCHAR(100) DEFAULT NULL COMMENT '真实姓名',
|
||
-- email 字段已删除,迁移到 rui_uc_user 表
|
||
avatar VARCHAR(1000) DEFAULT NULL COMMENT '头像URL',
|
||
gender TINYINT DEFAULT 0 COMMENT '性别 0:未知 1:男 2:女',
|
||
birthday DATE DEFAULT NULL,
|
||
id_card VARCHAR(18) DEFAULT NULL COMMENT '身份证号',
|
||
address VARCHAR(500) DEFAULT NULL,
|
||
extra JSON DEFAULT NULL COMMENT '扩展字段(JSON格式)',
|
||
remark VARCHAR(500) DEFAULT NULL,
|
||
created_by BIGINT DEFAULT NULL COMMENT '创建者ID',
|
||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
updated_by BIGINT DEFAULT NULL COMMENT '创建者ID',
|
||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uk_user_id (user_id),
|
||
INDEX idx_tenant (tenant_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户详情';
|
||
```
|
||
|
||
- [x] **Step 3: 新增 `rui_uc_user_social` 表**
|
||
|
||
在 `rui_sys_login_log` 表之前添加:
|
||
|
||
```sql
|
||
-- ----------------------------------------------------------------------------
|
||
-- 2.x 用户第三方账号关联(新增)
|
||
-- ----------------------------------------------------------------------------
|
||
|
||
CREATE TABLE rui_uc_user_social (
|
||
id BIGINT NOT NULL,
|
||
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||
provider VARCHAR(20) NOT NULL COMMENT '平台 wechat/alipay',
|
||
union_id VARCHAR(100) DEFAULT NULL COMMENT 'unionId(微信开放平台)',
|
||
open_id VARCHAR(100) NOT NULL COMMENT 'openId',
|
||
extra JSON DEFAULT NULL COMMENT '扩展信息(昵称、头像等)',
|
||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uk_user_provider (user_id, provider),
|
||
UNIQUE KEY uk_provider_openid (provider, open_id),
|
||
INDEX idx_union_id (union_id),
|
||
INDEX idx_user_id (user_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户第三方账号关联';
|
||
```
|
||
|
||
- [x] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add sql/init-database.sql
|
||
git commit -m "feat(db): 添加第三方登录支持的数据库变更
|
||
|
||
- rui_uc_user 表新增 email 字段
|
||
- rui_uc_user_detail 表删除 email 字段
|
||
- 新增 rui_uc_user_social 第三方账号关联表"
|
||
```
|
||
|
||
> **实际执行说明 (2026-06-07)**:email 字段位置在 init-database.sql 中改为 `AFTER username`(plan 写的是 `AFTER phone`,但当前 state rui_uc_user 表无 phone 字段;与 `sql/upgrade-v2.x-add-phone-to-user.sql` 的 `AFTER username` 惯例保持一致)。另有 init-database.sql 未反映 phone 迁移的 gap(见最终报告)。
|
||
|
||
---
|
||
|
||
## Task 2: rui-service-user 实体类调整
|
||
|
||
**Files:**
|
||
- Modify: `rui-service/rui-service-user/src/main/java/com/rui/service/user/entity/User.java`
|
||
- Modify: `rui-service/rui-service-user/src/main/java/com/rui/service/user/entity/UserDetail.java`
|
||
- Create: `rui-service/rui-service-user/src/main/java/com/rui/service/user/entity/UserSocial.java`
|
||
|
||
- [x] **Step 1: 在 `User` 实体添加 email 字段**
|
||
|
||
```java
|
||
@Schema(description = "邮箱")
|
||
@SearchField(alias = "email")
|
||
private String email;
|
||
```
|
||
|
||
在 `phone` 字段后添加,确保字段顺序:username → phone → email → userNo → password...
|
||
|
||
- [x] **Step 2: 从 `UserDetail` 实体删除 email 字段**
|
||
|
||
删除以下代码:
|
||
```java
|
||
@Schema(description = "邮箱")
|
||
@SearchField(alias = "email")
|
||
private String email;
|
||
```
|
||
|
||
- [x] **Step 3: 创建 `UserSocial` 实体**
|
||
|
||
```java
|
||
package com.rui.service.user.entity;
|
||
|
||
import com.baomidou.mybatisplus.annotation.TableName;
|
||
import com.rui.common.mybatis.model.BaseEntity;
|
||
import io.swagger.v3.oas.annotations.media.Schema;
|
||
import lombok.Data;
|
||
import lombok.EqualsAndHashCode;
|
||
|
||
/**
|
||
* 用户第三方账号关联
|
||
*/
|
||
@Data
|
||
@EqualsAndHashCode(callSuper = true)
|
||
@Schema(description = "用户第三方账号关联")
|
||
@TableName(value = "uc_user_social", keepGlobalPrefix = true)
|
||
public class UserSocial extends BaseEntity {
|
||
|
||
@Schema(description = "用户ID")
|
||
private Long userId;
|
||
|
||
@Schema(description = "平台 wechat/alipay")
|
||
private String provider;
|
||
|
||
@Schema(description = "unionId")
|
||
private String unionId;
|
||
|
||
@Schema(description = "openId")
|
||
private String openId;
|
||
|
||
@Schema(description = "扩展信息")
|
||
private String extra;
|
||
}
|
||
```
|
||
|
||
> **实际执行说明 (2026-06-07)**:UserSocial 实体添加了 `@TableField(exist = false)` 屏蔽 BaseEntity 中的 `deleted`/`createdBy`/`updatedBy`(SQL 表 rui_uc_user_social 中无这些列;按项目惯例 UserRole/UserDept/UserPost 处理)。
|
||
|
||
- [x] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add rui-service/rui-service-user/src/main/java/com/rui/service/user/entity/
|
||
git commit -m "feat(user): 调整用户实体支持第三方登录
|
||
|
||
- User 实体新增 email 字段
|
||
- UserDetail 实体删除 email 字段
|
||
- 新增 UserSocial 第三方账号关联实体"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: rui-service-user 数据访问层
|
||
|
||
**Files:**
|
||
- Create: `rui-service/rui-service-user/src/main/java/com/rui/service/user/mapper/UserSocialMapper.java`
|
||
- Create: `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/IUserSocialService.java`
|
||
- Create: `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/impl/UserSocialServiceImpl.java`
|
||
|
||
- [x] **Step 1: 创建 `UserSocialMapper`**
|
||
|
||
```java
|
||
package com.rui.service.user.mapper;
|
||
|
||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||
import com.rui.service.user.entity.UserSocial;
|
||
import org.apache.ibatis.annotations.Param;
|
||
import org.apache.ibatis.annotations.Select;
|
||
|
||
/**
|
||
* 用户第三方账号关联 Mapper
|
||
*/
|
||
public interface UserSocialMapper extends BaseMapper<UserSocial> {
|
||
|
||
/**
|
||
* 根据 unionId 查询
|
||
*/
|
||
@Select("SELECT * FROM rui_uc_user_social WHERE union_id = #{unionId} LIMIT 1")
|
||
UserSocial selectByUnionId(@Param("unionId") String unionId);
|
||
|
||
/**
|
||
* 根据 provider 和 openId 查询
|
||
*/
|
||
@Select("SELECT * FROM rui_uc_user_social WHERE provider = #{provider} AND open_id = #{openId} LIMIT 1")
|
||
UserSocial selectByOpenId(@Param("provider") String provider, @Param("openId") String openId);
|
||
|
||
/**
|
||
* 根据 userId 和 provider 查询
|
||
*/
|
||
@Select("SELECT * FROM rui_uc_user_social WHERE user_id = #{userId} AND provider = #{provider} LIMIT 1")
|
||
UserSocial selectByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
|
||
}
|
||
```
|
||
|
||
> **实际执行说明 (2026-06-07)**:SQL 中表名改为 `#prefix#uc_user_social`(项目惯例,见 UserRoleMapper/UserDeptMapper);Mapper 加 `@EnableRedisCache` 注解(项目最近 commit 启用)。
|
||
|
||
- [x] **Step 2: 创建 `IUserSocialService`**
|
||
|
||
```java
|
||
package com.rui.service.user.service;
|
||
|
||
import com.baomidou.mybatisplus.extension.service.IService;
|
||
import com.rui.service.user.entity.UserSocial;
|
||
|
||
/**
|
||
* 用户第三方账号关联服务接口
|
||
*/
|
||
public interface IUserSocialService extends IService<UserSocial> {
|
||
|
||
/**
|
||
* 根据 unionId 查询
|
||
*/
|
||
UserSocial findByUnionId(String unionId);
|
||
|
||
/**
|
||
* 根据 provider 和 openId 查询
|
||
*/
|
||
UserSocial findByOpenId(String provider, String openId);
|
||
|
||
/**
|
||
* 根据 userId 和 provider 查询
|
||
*/
|
||
UserSocial findByUserIdAndProvider(Long userId, String provider);
|
||
|
||
/**
|
||
* 绑定第三方账号
|
||
*/
|
||
boolean bindSocialAccount(Long userId, String provider, String unionId, String openId, String extra);
|
||
|
||
/**
|
||
* 解绑第三方账号
|
||
*/
|
||
boolean unbindSocialAccount(Long userId, String provider);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 3: 创建 `UserSocialServiceImpl`**
|
||
|
||
```java
|
||
package com.rui.service.user.service.impl;
|
||
|
||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||
import com.rui.service.user.entity.UserSocial;
|
||
import com.rui.service.user.mapper.UserSocialMapper;
|
||
import com.rui.service.user.service.IUserSocialService;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
/**
|
||
* 用户第三方账号关联服务实现
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class UserSocialServiceImpl extends ServiceImpl<UserSocialMapper, UserSocial> implements IUserSocialService {
|
||
|
||
private final UserSocialMapper userSocialMapper;
|
||
|
||
@Override
|
||
public UserSocial findByUnionId(String unionId) {
|
||
if (unionId == null || unionId.isEmpty()) {
|
||
return null;
|
||
}
|
||
return userSocialMapper.selectByUnionId(unionId);
|
||
}
|
||
|
||
@Override
|
||
public UserSocial findByOpenId(String provider, String openId) {
|
||
if (provider == null || openId == null) {
|
||
return null;
|
||
}
|
||
return userSocialMapper.selectByOpenId(provider, openId);
|
||
}
|
||
|
||
@Override
|
||
public UserSocial findByUserIdAndProvider(Long userId, String provider) {
|
||
if (userId == null || provider == null) {
|
||
return null;
|
||
}
|
||
return userSocialMapper.selectByUserIdAndProvider(userId, provider);
|
||
}
|
||
|
||
@Override
|
||
public boolean bindSocialAccount(Long userId, String provider, String unionId, String openId, String extra) {
|
||
// 检查是否已绑定
|
||
UserSocial exist = findByUserIdAndProvider(userId, provider);
|
||
if (exist != null) {
|
||
log.warn("用户已绑定该平台的账号: userId={}, provider={}", userId, provider);
|
||
return false;
|
||
}
|
||
|
||
UserSocial social = new UserSocial();
|
||
social.setUserId(userId);
|
||
social.setProvider(provider);
|
||
social.setUnionId(unionId);
|
||
social.setOpenId(openId);
|
||
social.setExtra(extra);
|
||
return save(social);
|
||
}
|
||
|
||
@Override
|
||
public boolean unbindSocialAccount(Long userId, String provider) {
|
||
UserSocial social = findByUserIdAndProvider(userId, provider);
|
||
if (social == null) {
|
||
return false;
|
||
}
|
||
return removeById(social.getId());
|
||
}
|
||
}
|
||
```
|
||
|
||
> **实际执行说明 (2026-06-07)**:删除冗余的 `userSocialMapper` 字段(ServiceImpl 已提供 `baseMapper`);bind/unbind 加 `@Transactional`(写操作事务化);bindSocialAccount 增加 `AuthUtil.getTenantId()` 设置 tenantId(与 UserPostServiceImpl 一致)。
|
||
|
||
- [x] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add rui-service/rui-service-user/src/main/java/com/rui/service/user/mapper/
|
||
git add rui-service/rui-service-user/src/main/java/com/rui/service/user/service/
|
||
git commit -m "feat(user): 添加第三方账号数据访问层
|
||
|
||
- UserSocialMapper 数据访问
|
||
- IUserSocialService 服务接口
|
||
- UserSocialServiceImpl 服务实现
|
||
- 支持 unionId/openId 查询和绑定/解绑"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: rui-service-user 内部接口扩展
|
||
|
||
**Files:**
|
||
- Modify: `rui-service/rui-service-user/src/main/java/com/rui/service/user/controller/inner/UserInnerController.java`
|
||
|
||
- [x] **Step 1: 扩展 `loadUser` 方法支持 EMAIL 类型**
|
||
|
||
修改 `loadUser` 方法的 switch 语句,添加 EMAIL case:
|
||
|
||
```java
|
||
@PostMapping("/auth/load")
|
||
public Result<JSONObject> loadUser(@RequestBody LoginAccountDTO loginAccount) {
|
||
if (loginAccount == null || loginAccount.getAccount() == null || loginAccount.getAccountType() == null) {
|
||
return Result.fail(400, "账号和账号类型不能为空");
|
||
}
|
||
|
||
User user;
|
||
String account = loginAccount.getAccount();
|
||
AccountType accountType = loginAccount.getAccountType();
|
||
|
||
switch (accountType) {
|
||
case PHONE:
|
||
user = userService.lambdaQuery()
|
||
.eq(User::getPhone, account)
|
||
.one();
|
||
break;
|
||
case EMAIL:
|
||
user = userService.lambdaQuery()
|
||
.eq(User::getEmail, account)
|
||
.one();
|
||
break;
|
||
case USERNAME:
|
||
default:
|
||
user = userService.lambdaQuery()
|
||
.eq(User::getUsername, account)
|
||
.one();
|
||
break;
|
||
}
|
||
|
||
return Result.ok(buildAuthInfo(user));
|
||
}
|
||
```
|
||
|
||
注意:删除 EMAIL 的 "邮箱登录暂未支持" 返回。
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add rui-service/rui-service-user/src/main/java/com/rui/service/user/controller/inner/UserInnerController.java
|
||
git commit -m "feat(user): 扩展内部接口支持 EMAIL 账号类型查询"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: rui-common-oauth2 扩展密码登录
|
||
|
||
**Files:**
|
||
- Modify: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/password/PasswordAuthenticationConverter.java`
|
||
- Modify: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/service/RemoteUserDetailsService.java`
|
||
|
||
- [ ] **Step 1: 修改 `PasswordAuthenticationConverter` 支持 accountType**
|
||
|
||
重写 `checkParams` 方法:
|
||
|
||
```java
|
||
@Override
|
||
public void checkParams(HttpServletRequest request) {
|
||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||
|
||
// 兼容模式:使用 username
|
||
String username = parameters.getFirst("username");
|
||
if (StringUtils.hasText(username)) {
|
||
// 校验 password
|
||
String password = parameters.getFirst("password");
|
||
if (!StringUtils.hasText(password) || parameters.get("password").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "password",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 扩展模式:使用 account + accountType
|
||
String account = parameters.getFirst("account");
|
||
if (!StringUtils.hasText(account) || parameters.get("account").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "account",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
|
||
String accountType = parameters.getFirst("accountType");
|
||
if (!StringUtils.hasText(accountType) || parameters.get("accountType").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "accountType",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
|
||
// 校验 accountType 有效性
|
||
if (!isValidAccountType(accountType)) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "accountType",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
|
||
// 校验 password
|
||
String password = parameters.getFirst("password");
|
||
if (!StringUtils.hasText(password) || parameters.get("password").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "password",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
}
|
||
|
||
private boolean isValidAccountType(String accountType) {
|
||
return "USERNAME".equals(accountType) || "PHONE".equals(accountType) || "EMAIL".equals(accountType);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 `PasswordAuthenticationProvider.buildToken`**
|
||
|
||
```java
|
||
@Override
|
||
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
|
||
String username = (String) reqParameters.get("username");
|
||
if (StringUtils.hasText(username)) {
|
||
// 兼容模式
|
||
String password = (String) reqParameters.get("password");
|
||
return new UsernamePasswordAuthenticationToken(username, password);
|
||
}
|
||
|
||
// 扩展模式
|
||
String account = (String) reqParameters.get("account");
|
||
String accountType = (String) reqParameters.get("accountType");
|
||
String password = (String) reqParameters.get("password");
|
||
|
||
// 将 accountType 编码到 principal 中,用 # 分隔
|
||
String principal = account + "#" + accountType;
|
||
return new UsernamePasswordAuthenticationToken(principal, password);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 修改 `RemoteUserDetailsService` 支持 EMAIL**
|
||
|
||
修改 `loadUserByAccount` 方法,添加 EMAIL 支持:
|
||
|
||
```java
|
||
public UserDetails loadUserByAccount(String account, String accountType) throws UsernameNotFoundException {
|
||
String cacheKey = String.format(CACHE_KEY, account);
|
||
JSONObject info = getCache(cacheKey);
|
||
if (info == null) {
|
||
try {
|
||
Result<JSONObject> result;
|
||
if ("USERNAME".equals(accountType)) {
|
||
result = userAuthFeign.loadUser(account);
|
||
} else {
|
||
Map<String, Object> loginAccount = Map.of(
|
||
"account", account,
|
||
"accountType", accountType
|
||
);
|
||
result = userAuthFeign.loadUser(loginAccount);
|
||
}
|
||
// ... 原有逻辑
|
||
} catch (Exception e) {
|
||
// ... 原有逻辑
|
||
}
|
||
}
|
||
return buildUserDetails(info, account);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/password/
|
||
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/service/RemoteUserDetailsService.java
|
||
git commit -m "feat(oauth2): 扩展密码登录支持多账号类型
|
||
|
||
- PasswordAuthenticationConverter 支持 account + accountType
|
||
- 支持 USERNAME/PHONE/EMAIL 三种类型
|
||
- RemoteUserDetailsService 支持 EMAIL 类型查询"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: rui-common-oauth2 短信登录实现
|
||
|
||
**Files:**
|
||
- Create: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/sms/SmsAuthenticationToken.java`
|
||
- Rewrite: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/sms/SmsAuthenticationConverter.java`
|
||
- Rewrite: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/sms/SmsAuthenticationProvider.java`
|
||
|
||
- [ ] **Step 1: 创建 `SmsAuthenticationToken`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.sms;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAbstractAuthenticationToken;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 短信授权认证令牌
|
||
*/
|
||
public class SmsAuthenticationToken extends BaseAbstractAuthenticationToken {
|
||
|
||
private static final AuthorizationGrantType SMS_GRANT_TYPE = new AuthorizationGrantType("sms");
|
||
|
||
public SmsAuthenticationToken(Authentication clientPrincipal, Set<String> scopes,
|
||
Map<String, Object> additionalParameters) {
|
||
super(SMS_GRANT_TYPE, clientPrincipal, scopes, additionalParameters);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 重写 `SmsAuthenticationConverter`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.sms;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAuthenticationConverter;
|
||
import com.rui.common.oauth2.util.OAuth2EndpointUtils;
|
||
import jakarta.servlet.http.HttpServletRequest;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||
import org.springframework.util.MultiValueMap;
|
||
import org.springframework.util.StringUtils;
|
||
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 短信授权模式的请求转换器
|
||
*/
|
||
public class SmsAuthenticationConverter extends BaseAuthenticationConverter<SmsAuthenticationToken> {
|
||
|
||
private static final AuthorizationGrantType SMS_GRANT_TYPE = new AuthorizationGrantType("sms");
|
||
|
||
@Override
|
||
public boolean support(String grantType) {
|
||
return SMS_GRANT_TYPE.getValue().equals(grantType);
|
||
}
|
||
|
||
@Override
|
||
public SmsAuthenticationToken buildToken(Authentication clientPrincipal, Set<String> requestedScopes,
|
||
Map<String, Object> additionalParameters) {
|
||
return new SmsAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters);
|
||
}
|
||
|
||
@Override
|
||
public void checkParams(HttpServletRequest request) {
|
||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||
|
||
// 校验 phone
|
||
String phone = parameters.getFirst("phone");
|
||
if (!StringUtils.hasText(phone) || parameters.get("phone").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "phone",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
|
||
// 校验 code
|
||
String code = parameters.getFirst("code");
|
||
if (!StringUtils.hasText(code) || parameters.get("code").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "code",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 重写 `SmsAuthenticationProvider`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.sms;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAuthenticationProvider;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.security.authentication.AuthenticationManager;
|
||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.*;
|
||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 短信授权模式的认证提供者
|
||
*/
|
||
@Slf4j
|
||
public class SmsAuthenticationProvider extends BaseAuthenticationProvider<SmsAuthenticationToken> {
|
||
|
||
public SmsAuthenticationProvider(AuthenticationManager authenticationManager,
|
||
OAuth2AuthorizationService authorizationService,
|
||
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {
|
||
super(authenticationManager, authorizationService, tokenGenerator);
|
||
}
|
||
|
||
@Override
|
||
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
|
||
String phone = (String) reqParameters.get("phone");
|
||
// 短信登录不需要密码,使用 phone 作为 principal
|
||
// 验证码校验在 authenticationManager 中进行
|
||
return new UsernamePasswordAuthenticationToken(phone, null);
|
||
}
|
||
|
||
@Override
|
||
public boolean supports(Class<? extends Authentication> authentication) {
|
||
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
|
||
}
|
||
|
||
@Override
|
||
public void checkClient(RegisteredClient registeredClient) {
|
||
if (registeredClient == null || !registeredClient.getAuthorizationGrantTypes().contains(new AuthorizationGrantType("sms"))) {
|
||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/sms/
|
||
git commit -m "feat(oauth2): 实现短信登录框架
|
||
|
||
- SmsAuthenticationToken 短信认证令牌
|
||
- SmsAuthenticationConverter 参数提取和校验
|
||
- SmsAuthenticationProvider 短信认证逻辑
|
||
- 支持 phone + code 登录模式"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: rui-common-oauth2 微信登录实现
|
||
|
||
**Files:**
|
||
- Create: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WeixinAuthenticationToken.java`
|
||
- Rewrite: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WeixinAuthenticationConverter.java`
|
||
- Rewrite: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WeixinAuthenticationProvider.java`
|
||
- Create: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WechatApiClient.java`
|
||
|
||
- [ ] **Step 1: 创建 `WeixinAuthenticationToken`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.weixin;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAbstractAuthenticationToken;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 微信授权认证令牌
|
||
*/
|
||
public class WeixinAuthenticationToken extends BaseAbstractAuthenticationToken {
|
||
|
||
private static final AuthorizationGrantType WECHAT_GRANT_TYPE = new AuthorizationGrantType("wechat");
|
||
|
||
public WeixinAuthenticationToken(Authentication clientPrincipal, Set<String> scopes,
|
||
Map<String, Object> additionalParameters) {
|
||
super(WECHAT_GRANT_TYPE, clientPrincipal, scopes, additionalParameters);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 `WechatApiClient`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.weixin;
|
||
|
||
import com.alibaba.fastjson2.JSON;
|
||
import com.alibaba.fastjson2.JSONObject;
|
||
import com.rui.common.core.util.HttpUtil;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 微信 API 客户端
|
||
*/
|
||
@Slf4j
|
||
public class WechatApiClient {
|
||
|
||
private static final String TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token";
|
||
private static final String USER_INFO_URL = "https://api.weixin.qq.com/sns/userinfo";
|
||
|
||
private final String appId;
|
||
private final String appSecret;
|
||
private final HttpUtil httpUtil;
|
||
|
||
public WechatApiClient(String appId, String appSecret) {
|
||
this.appId = appId;
|
||
this.appSecret = appSecret;
|
||
this.httpUtil = new HttpUtil();
|
||
}
|
||
|
||
/**
|
||
* 使用授权码换取 access_token
|
||
*/
|
||
public WechatTokenResponse getAccessToken(String code) {
|
||
String url = TOKEN_URL + "?appid=" + appId + "&secret=" + appSecret + "&code=" + code + "&grant_type=authorization_code";
|
||
String response = httpUtil.executeRaw(url, null, null, "GET");
|
||
log.debug("微信 token 响应: {}", response);
|
||
|
||
JSONObject json = JSON.parseObject(response);
|
||
if (json.containsKey("errcode")) {
|
||
throw new RuntimeException("微信授权失败: " + json.getString("errmsg"));
|
||
}
|
||
|
||
WechatTokenResponse result = new WechatTokenResponse();
|
||
result.setAccessToken(json.getString("access_token"));
|
||
result.setOpenid(json.getString("openid"));
|
||
result.setUnionid(json.getString("unionid"));
|
||
result.setExpiresIn(json.getIntValue("expires_in"));
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 获取用户信息
|
||
*/
|
||
public WechatUserInfo getUserInfo(String accessToken, String openId) {
|
||
String url = USER_INFO_URL + "?access_token=" + accessToken + "&openid=" + openId;
|
||
String response = httpUtil.executeRaw(url, null, null, "GET");
|
||
log.debug("微信用户信息响应: {}", response);
|
||
|
||
JSONObject json = JSON.parseObject(response);
|
||
if (json.containsKey("errcode")) {
|
||
throw new RuntimeException("获取微信用户信息失败: " + json.getString("errmsg"));
|
||
}
|
||
|
||
WechatUserInfo result = new WechatUserInfo();
|
||
result.setOpenid(json.getString("openid"));
|
||
result.setUnionid(json.getString("unionid"));
|
||
result.setNickname(json.getString("nickname"));
|
||
result.setHeadimgurl(json.getString("headimgurl"));
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Token 响应
|
||
*/
|
||
public static class WechatTokenResponse {
|
||
private String accessToken;
|
||
private String openid;
|
||
private String unionid;
|
||
private int expiresIn;
|
||
|
||
// getters and setters
|
||
public String getAccessToken() { return accessToken; }
|
||
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
|
||
public String getOpenid() { return openid; }
|
||
public void setOpenid(String openid) { this.openid = openid; }
|
||
public String getUnionid() { return unionid; }
|
||
public void setUnionid(String unionid) { this.unionid = unionid; }
|
||
public int getExpiresIn() { return expiresIn; }
|
||
public void setExpiresIn(int expiresIn) { this.expiresIn = expiresIn; }
|
||
}
|
||
|
||
/**
|
||
* 用户信息
|
||
*/
|
||
public static class WechatUserInfo {
|
||
private String openid;
|
||
private String unionid;
|
||
private String nickname;
|
||
private String headimgurl;
|
||
|
||
// getters and setters
|
||
public String getOpenid() { return openid; }
|
||
public void setOpenid(String openid) { this.openid = openid; }
|
||
public String getUnionid() { return unionid; }
|
||
public void setUnionid(String unionid) { this.unionid = unionid; }
|
||
public String getNickname() { return nickname; }
|
||
public void setNickname(String nickname) { this.nickname = nickname; }
|
||
public String getHeadimgurl() { return headimgurl; }
|
||
public void setHeadimgurl(String headimgurl) { this.headimgurl = headimgurl; }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 重写 `WeixinAuthenticationConverter`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.weixin;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAuthenticationConverter;
|
||
import com.rui.common.oauth2.util.OAuth2EndpointUtils;
|
||
import jakarta.servlet.http.HttpServletRequest;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||
import org.springframework.util.MultiValueMap;
|
||
import org.springframework.util.StringUtils;
|
||
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 微信授权模式的请求转换器
|
||
*/
|
||
public class WeixinAuthenticationConverter extends BaseAuthenticationConverter<WeixinAuthenticationToken> {
|
||
|
||
private static final AuthorizationGrantType WECHAT_GRANT_TYPE = new AuthorizationGrantType("wechat");
|
||
|
||
@Override
|
||
public boolean support(String grantType) {
|
||
return WECHAT_GRANT_TYPE.getValue().equals(grantType);
|
||
}
|
||
|
||
@Override
|
||
public WeixinAuthenticationToken buildToken(Authentication clientPrincipal, Set<String> requestedScopes,
|
||
Map<String, Object> additionalParameters) {
|
||
return new WeixinAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters);
|
||
}
|
||
|
||
@Override
|
||
public void checkParams(HttpServletRequest request) {
|
||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||
|
||
// 校验 code
|
||
String code = parameters.getFirst("code");
|
||
if (!StringUtils.hasText(code) || parameters.get("code").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "code",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 重写 `WeixinAuthenticationProvider`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.weixin;
|
||
|
||
import com.alibaba.fastjson2.JSON;
|
||
import com.alibaba.fastjson2.JSONObject;
|
||
import com.rui.common.core.result.Result;
|
||
import com.rui.common.oauth2.authentication.BaseAuthenticationProvider;
|
||
import com.rui.common.oauth2.feign.UserAuthFeign;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.security.authentication.AuthenticationManager;
|
||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.*;
|
||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 微信授权模式的认证提供者
|
||
*/
|
||
@Slf4j
|
||
public class WeixinAuthenticationProvider extends BaseAuthenticationProvider<WeixinAuthenticationToken> {
|
||
|
||
private final WechatApiClient wechatApiClient;
|
||
private final UserAuthFeign userAuthFeign;
|
||
|
||
public WeixinAuthenticationProvider(AuthenticationManager authenticationManager,
|
||
OAuth2AuthorizationService authorizationService,
|
||
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
|
||
WechatApiClient wechatApiClient,
|
||
UserAuthFeign userAuthFeign) {
|
||
super(authenticationManager, authorizationService, tokenGenerator);
|
||
this.wechatApiClient = wechatApiClient;
|
||
this.userAuthFeign = userAuthFeign;
|
||
}
|
||
|
||
@Override
|
||
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
|
||
String code = (String) reqParameters.get("code");
|
||
String phone = (String) reqParameters.get("phone");
|
||
|
||
// 调用微信 API 获取 openId 和 unionId
|
||
WechatApiClient.WechatTokenResponse wxResponse = wechatApiClient.getAccessToken(code);
|
||
String openId = wxResponse.getOpenid();
|
||
String unionId = wxResponse.getUnionid();
|
||
|
||
log.info("微信登录: openId={}, unionId={}, phone={}", openId, unionId, phone);
|
||
|
||
// 查找或创建用户
|
||
// TODO: 这里需要调用 UserSocialService 查询绑定关系
|
||
// 暂时使用 openId 作为 principal
|
||
String principal = openId + "#" + unionId + "#" + (phone != null ? phone : "");
|
||
return new UsernamePasswordAuthenticationToken(principal, null);
|
||
}
|
||
|
||
@Override
|
||
public boolean supports(Class<? extends Authentication> authentication) {
|
||
return WeixinAuthenticationToken.class.isAssignableFrom(authentication);
|
||
}
|
||
|
||
@Override
|
||
public void checkClient(RegisteredClient registeredClient) {
|
||
if (registeredClient == null || !registeredClient.getAuthorizationGrantTypes().contains(new AuthorizationGrantType("wechat"))) {
|
||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/
|
||
git commit -m "feat(oauth2): 实现微信登录框架
|
||
|
||
- WeixinAuthenticationToken 微信认证令牌
|
||
- WeixinAuthenticationConverter 参数提取
|
||
- WeixinAuthenticationProvider 微信认证逻辑
|
||
- WechatApiClient 微信 API 客户端封装
|
||
- 支持 code 换取用户信息"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: rui-common-oauth2 支付宝登录实现
|
||
|
||
**Files:**
|
||
- Create: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationToken.java`
|
||
- Rewrite: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationConverter.java`
|
||
- Rewrite: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationProvider.java`
|
||
- Create: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayApiClient.java`
|
||
|
||
- [ ] **Step 1: 创建 `AlipayAuthenticationToken`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.alipay;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAbstractAuthenticationToken;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 支付宝授权认证令牌
|
||
*/
|
||
public class AlipayAuthenticationToken extends BaseAbstractAuthenticationToken {
|
||
|
||
private static final AuthorizationGrantType ALIPAY_GRANT_TYPE = new AuthorizationGrantType("alipay");
|
||
|
||
public AlipayAuthenticationToken(Authentication clientPrincipal, Set<String> scopes,
|
||
Map<String, Object> additionalParameters) {
|
||
super(ALIPAY_GRANT_TYPE, clientPrincipal, scopes, additionalParameters);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 `AlipayApiClient`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.alipay;
|
||
|
||
import com.alibaba.fastjson2.JSON;
|
||
import com.alibaba.fastjson2.JSONObject;
|
||
import com.rui.common.core.util.HttpUtil;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
|
||
/**
|
||
* 支付宝 API 客户端
|
||
*/
|
||
@Slf4j
|
||
public class AlipayApiClient {
|
||
|
||
private static final String GATEWAY_URL = "https://openapi.alipay.com/gateway.do";
|
||
|
||
private final String appId;
|
||
private final String privateKey;
|
||
private final String publicKey;
|
||
private final HttpUtil httpUtil;
|
||
|
||
public AlipayApiClient(String appId, String privateKey, String publicKey) {
|
||
this.appId = appId;
|
||
this.privateKey = privateKey;
|
||
this.publicKey = publicKey;
|
||
this.httpUtil = new HttpUtil();
|
||
}
|
||
|
||
/**
|
||
* 使用授权码换取 access_token
|
||
*/
|
||
public AlipayTokenResponse getAccessToken(String code) {
|
||
// TODO: 实现支付宝授权码换取 token
|
||
// 这里需要集成支付宝 SDK 或自行实现签名逻辑
|
||
log.warn("支付宝登录暂未完全实现,需要接入支付宝 SDK");
|
||
throw new UnsupportedOperationException("支付宝登录暂未完全实现");
|
||
}
|
||
|
||
/**
|
||
* 获取用户信息
|
||
*/
|
||
public AlipayUserInfo getUserInfo(String accessToken) {
|
||
// TODO: 实现获取支付宝用户信息
|
||
log.warn("支付宝登录暂未完全实现,需要接入支付宝 SDK");
|
||
throw new UnsupportedOperationException("支付宝登录暂未完全实现");
|
||
}
|
||
|
||
/**
|
||
* Token 响应
|
||
*/
|
||
public static class AlipayTokenResponse {
|
||
private String accessToken;
|
||
private String userId;
|
||
private int expiresIn;
|
||
|
||
// getters and setters
|
||
public String getAccessToken() { return accessToken; }
|
||
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
|
||
public String getUserId() { return userId; }
|
||
public void setUserId(String userId) { this.userId = userId; }
|
||
public int getExpiresIn() { return expiresIn; }
|
||
public void setExpiresIn(int expiresIn) { this.expiresIn = expiresIn; }
|
||
}
|
||
|
||
/**
|
||
* 用户信息
|
||
*/
|
||
public static class AlipayUserInfo {
|
||
private String userId;
|
||
private String nickname;
|
||
private String avatar;
|
||
|
||
// getters and setters
|
||
public String getUserId() { return userId; }
|
||
public void setUserId(String userId) { this.userId = userId; }
|
||
public String getNickname() { return nickname; }
|
||
public void setNickname(String nickname) { this.nickname = nickname; }
|
||
public String getAvatar() { return avatar; }
|
||
public void setAvatar(String avatar) { this.avatar = avatar; }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 重写 `AlipayAuthenticationConverter`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.alipay;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAuthenticationConverter;
|
||
import com.rui.common.oauth2.util.OAuth2EndpointUtils;
|
||
import jakarta.servlet.http.HttpServletRequest;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||
import org.springframework.util.MultiValueMap;
|
||
import org.springframework.util.StringUtils;
|
||
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 支付宝授权模式的请求转换器
|
||
*/
|
||
public class AlipayAuthenticationConverter extends BaseAuthenticationConverter<AlipayAuthenticationToken> {
|
||
|
||
private static final AuthorizationGrantType ALIPAY_GRANT_TYPE = new AuthorizationGrantType("alipay");
|
||
|
||
@Override
|
||
public boolean support(String grantType) {
|
||
return ALIPAY_GRANT_TYPE.getValue().equals(grantType);
|
||
}
|
||
|
||
@Override
|
||
public AlipayAuthenticationToken buildToken(Authentication clientPrincipal, Set<String> requestedScopes,
|
||
Map<String, Object> additionalParameters) {
|
||
return new AlipayAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters);
|
||
}
|
||
|
||
@Override
|
||
public void checkParams(HttpServletRequest request) {
|
||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||
|
||
// 校验 code
|
||
String code = parameters.getFirst("code");
|
||
if (!StringUtils.hasText(code) || parameters.get("code").size() != 1) {
|
||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "code",
|
||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 重写 `AlipayAuthenticationProvider`**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.authentication.alipay;
|
||
|
||
import com.rui.common.oauth2.authentication.BaseAuthenticationProvider;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.security.authentication.AuthenticationManager;
|
||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.core.*;
|
||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 支付宝授权模式的认证提供者
|
||
*/
|
||
@Slf4j
|
||
public class AlipayAuthenticationProvider extends BaseAuthenticationProvider<AlipayAuthenticationToken> {
|
||
|
||
private final AlipayApiClient alipayApiClient;
|
||
|
||
public AlipayAuthenticationProvider(AuthenticationManager authenticationManager,
|
||
OAuth2AuthorizationService authorizationService,
|
||
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
|
||
AlipayApiClient alipayApiClient) {
|
||
super(authenticationManager, authorizationService, tokenGenerator);
|
||
this.alipayApiClient = alipayApiClient;
|
||
}
|
||
|
||
@Override
|
||
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
|
||
String code = (String) reqParameters.get("code");
|
||
String phone = (String) reqParameters.get("phone");
|
||
|
||
// 调用支付宝 API 获取 userId
|
||
AlipayApiClient.AlipayTokenResponse alipayResponse = alipayApiClient.getAccessToken(code);
|
||
String userId = alipayResponse.getUserId();
|
||
|
||
log.info("支付宝登录: userId={}, phone={}", userId, phone);
|
||
|
||
// TODO: 查找或创建用户
|
||
String principal = userId + "#" + (phone != null ? phone : "");
|
||
return new UsernamePasswordAuthenticationToken(principal, null);
|
||
}
|
||
|
||
@Override
|
||
public boolean supports(Class<? extends Authentication> authentication) {
|
||
return AlipayAuthenticationToken.class.isAssignableFrom(authentication);
|
||
}
|
||
|
||
@Override
|
||
public void checkClient(RegisteredClient registeredClient) {
|
||
if (registeredClient == null || !registeredClient.getAuthorizationGrantTypes().contains(new AuthorizationGrantType("alipay"))) {
|
||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/
|
||
git commit -m "feat(oauth2): 实现支付宝登录框架
|
||
|
||
- AlipayAuthenticationToken 支付宝认证令牌
|
||
- AlipayAuthenticationConverter 参数提取
|
||
- AlipayAuthenticationProvider 支付宝认证逻辑
|
||
- AlipayApiClient 支付宝 API 客户端(预留,需接入 SDK)
|
||
- 支持 code 换取用户信息"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: rui-common-oauth2 配置注册
|
||
|
||
**Files:**
|
||
- Modify: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/config/OAuth2ServerConfig.java`
|
||
|
||
- [ ] **Step 1: 修改 `OAuth2ServerConfig` 注册新的 Converter 和 Provider**
|
||
|
||
```java
|
||
package com.rui.common.oauth2.config;
|
||
|
||
import com.rui.common.oauth2.authentication.alipay.AlipayApiClient;
|
||
import com.rui.common.oauth2.authentication.alipay.AlipayAuthenticationConverter;
|
||
import com.rui.common.oauth2.authentication.alipay.AlipayAuthenticationProvider;
|
||
import com.rui.common.oauth2.authentication.password.PasswordAuthenticationConverter;
|
||
import com.rui.common.oauth2.authentication.password.PasswordAuthenticationProvider;
|
||
import com.rui.common.oauth2.authentication.sms.SmsAuthenticationConverter;
|
||
import com.rui.common.oauth2.authentication.sms.SmsAuthenticationProvider;
|
||
import com.rui.common.oauth2.authentication.weixin.WechatApiClient;
|
||
import com.rui.common.oauth2.authentication.weixin.WeixinAuthenticationConverter;
|
||
import com.rui.common.oauth2.authentication.weixin.WeixinAuthenticationProvider;
|
||
import com.rui.common.oauth2.feign.UserAuthFeign;
|
||
import com.rui.common.oauth2.handler.LoginFailureHandler;
|
||
import com.rui.common.oauth2.handler.LoginSuccessHandler;
|
||
import com.rui.common.oauth2.service.RedisOAuth2AuthorizationService;
|
||
import com.rui.common.oauth2.token.RuiTokenCustomizer;
|
||
import com.rui.common.redis.util.RedisUtil;
|
||
import com.rui.common.security.config.PermitAllUrlProperties;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.ComponentScan;
|
||
import org.springframework.context.annotation.Configuration;
|
||
import org.springframework.security.authentication.AuthenticationManager;
|
||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
|
||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||
import org.springframework.security.oauth2.server.authorization.token.*;
|
||
import org.springframework.security.oauth2.server.authorization.web.authentication.*;
|
||
import org.springframework.security.web.SecurityFilterChain;
|
||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||
import org.springframework.security.web.authentication.DelegatingAuthenticationConverter;
|
||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||
|
||
import java.util.Arrays;
|
||
|
||
@Slf4j
|
||
@Configuration
|
||
@EnableWebSecurity
|
||
@ComponentScan("com.rui.common.oauth2")
|
||
public class OAuth2ServerConfig {
|
||
|
||
@Value("${social.wechat.app-id:}")
|
||
private String wechatAppId;
|
||
|
||
@Value("${social.wechat.app-secret:}")
|
||
private String wechatAppSecret;
|
||
|
||
@Value("${social.alipay.app-id:}")
|
||
private String alipayAppId;
|
||
|
||
@Value("${social.alipay.private-key:}")
|
||
private String alipayPrivateKey;
|
||
|
||
@Value("${social.alipay.public-key:}")
|
||
private String alipayPublicKey;
|
||
|
||
// ... 原有 bean 定义 ...
|
||
|
||
@Bean
|
||
public SecurityFilterChain authorizationServerFilterChain(HttpSecurity http,
|
||
OAuth2AuthorizationService authorizationService, OAuth2TokenGenerator<?> tokenGenerator,
|
||
AuthenticationManager authenticationManager, PermitAllUrlProperties permitAllProperties,
|
||
UserAuthFeign userAuthFeign) throws Exception {
|
||
|
||
OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer();
|
||
|
||
http.securityMatcher(configurer.getEndpointsMatcher())
|
||
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
||
.csrf(csrf -> csrf.ignoringRequestMatchers(configurer.getEndpointsMatcher()))
|
||
.formLogin(AbstractHttpConfigurer::disable)
|
||
.httpBasic(AbstractHttpConfigurer::disable)
|
||
.apply(configurer
|
||
.tokenEndpoint(token -> token
|
||
.accessTokenRequestConverter(accessTokenRequestConverter())
|
||
.accessTokenResponseHandler(loginSuccessHandler())
|
||
.errorResponseHandler(loginFailureHandler()))
|
||
.clientAuthentication(client -> client
|
||
.errorResponseHandler(loginFailureHandler()))
|
||
);
|
||
|
||
// 注册认证 Provider
|
||
PasswordAuthenticationProvider passwordProvider = new PasswordAuthenticationProvider(
|
||
authenticationManager, authorizationService, tokenGenerator);
|
||
|
||
SmsAuthenticationProvider smsProvider = new SmsAuthenticationProvider(
|
||
authenticationManager, authorizationService, tokenGenerator);
|
||
|
||
WechatApiClient wechatApiClient = new WechatApiClient(wechatAppId, wechatAppSecret);
|
||
WeixinAuthenticationProvider wechatProvider = new WeixinAuthenticationProvider(
|
||
authenticationManager, authorizationService, tokenGenerator, wechatApiClient, userAuthFeign);
|
||
|
||
AlipayApiClient alipayApiClient = new AlipayApiClient(alipayAppId, alipayPrivateKey, alipayPublicKey);
|
||
AlipayAuthenticationProvider alipayProvider = new AlipayAuthenticationProvider(
|
||
authenticationManager, authorizationService, tokenGenerator, alipayApiClient);
|
||
|
||
http.authenticationProvider(passwordProvider)
|
||
.authenticationProvider(smsProvider)
|
||
.authenticationProvider(wechatProvider)
|
||
.authenticationProvider(alipayProvider);
|
||
|
||
return http.build();
|
||
}
|
||
|
||
/**
|
||
* 认证转换器链
|
||
*/
|
||
private AuthenticationConverter accessTokenRequestConverter() {
|
||
return new DelegatingAuthenticationConverter(Arrays.asList(
|
||
new PasswordAuthenticationConverter(),
|
||
new SmsAuthenticationConverter(),
|
||
new WeixinAuthenticationConverter(),
|
||
new AlipayAuthenticationConverter(),
|
||
new OAuth2RefreshTokenAuthenticationConverter(),
|
||
new OAuth2ClientCredentialsAuthenticationConverter()
|
||
));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/config/OAuth2ServerConfig.java
|
||
git commit -m "feat(oauth2): 注册新的登录方式到认证服务器
|
||
|
||
- 注册 SmsAuthenticationProvider
|
||
- 注册 WeixinAuthenticationProvider(含 WechatApiClient)
|
||
- 注册 AlipayAuthenticationProvider(含 AlipayApiClient)
|
||
- 更新 accessTokenRequestConverter 链"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: 配置更新
|
||
|
||
**Files:**
|
||
- Modify: `docs/backend/config-templates/nacos/rui-service-auth.yaml`(或对应配置文件)
|
||
|
||
- [ ] **Step 1: 添加社交登录配置**
|
||
|
||
在配置文件中添加:
|
||
|
||
```yaml
|
||
social:
|
||
wechat:
|
||
app-id: ${WECHAT_APP_ID:}
|
||
app-secret: ${WECHAT_APP_SECRET:}
|
||
alipay:
|
||
app-id: ${ALIPAY_APP_ID:}
|
||
private-key: ${ALIPAY_PRIVATE_KEY:}
|
||
public-key: ${ALIPAY_PUBLIC_KEY:}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/backend/config-templates/nacos/rui-service-auth.yaml
|
||
git commit -m "feat(config): 添加社交登录配置模板
|
||
|
||
- 微信登录配置(app-id, app-secret)
|
||
- 支付宝登录配置(app-id, private-key, public-key)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: 客户端授权类型更新
|
||
|
||
**Files:**
|
||
- Modify: `sql/init-database.sql`(更新默认客户端配置)
|
||
|
||
- [ ] **Step 1: 更新默认客户端授权类型**
|
||
|
||
找到 `sys_oauth_client` 表的 INSERT 语句,更新 grant_types:
|
||
|
||
```sql
|
||
-- 更新默认客户端,支持所有登录方式
|
||
UPDATE sys_oauth_client
|
||
SET authorized_grant_types = 'password,refresh_token,client_credentials,sms,wechat,alipay'
|
||
WHERE client_id = 'rui-client';
|
||
```
|
||
|
||
或者在 INSERT 语句中修改:
|
||
|
||
```sql
|
||
INSERT INTO sys_oauth_client (id, client_id, client_secret, client_name, ...)
|
||
VALUES (1, 'rui-client', '{noop}rui-secret', '睿核默认客户端', ...,
|
||
'password,refresh_token,client_credentials,sms,wechat,alipay', ...);
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add sql/init-database.sql
|
||
git commit -m "feat(db): 更新默认客户端支持新的授权类型
|
||
|
||
- 添加 sms, wechat, alipay 授权类型"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 编译验证
|
||
|
||
**Files:**
|
||
- All modified files
|
||
|
||
- [ ] **Step 1: 编译项目**
|
||
|
||
```bash
|
||
mvn clean compile -DskipTests
|
||
```
|
||
|
||
- [ ] **Step 2: 检查编译错误**
|
||
|
||
如果有编译错误,根据错误信息修复:
|
||
- 缺少依赖
|
||
- 类不存在
|
||
- 方法签名不匹配
|
||
- 导入错误
|
||
|
||
- [ ] **Step 3: Commit(如需要修复)**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "fix: 修复编译错误"
|
||
```
|
||
|
||
---
|
||
|
||
## 实施计划检查清单
|
||
|
||
### 规范覆盖检查
|
||
|
||
| 规范要求 | 对应任务 | 状态 |
|
||
|---------|---------|------|
|
||
| 数据库变更(新增表、修改字段) | Task 1 | ✅ |
|
||
| 实体类调整(User 加 email,UserDetail 删 email) | Task 2 | ✅ |
|
||
| 第三方账号实体(UserSocial) | Task 2 | ✅ |
|
||
| 数据访问层(Mapper + Service) | Task 3 | ✅ |
|
||
| 内部接口扩展(EMAIL 支持) | Task 4 | ✅ |
|
||
| 密码登录扩展(accountType) | Task 5 | ✅ |
|
||
| 短信登录框架 | Task 6 | ✅ |
|
||
| 微信登录框架 | Task 7 | ✅ |
|
||
| 支付宝登录框架 | Task 8 | ✅ |
|
||
| 配置注册(Converter + Provider) | Task 9 | ✅ |
|
||
| 配置文件更新 | Task 10 | ✅ |
|
||
| 客户端授权类型更新 | Task 11 | ✅ |
|
||
| 编译验证 | Task 12 | ✅ |
|
||
|
||
### 无占位符检查
|
||
|
||
- [x] 无 "TBD"、"TODO"、"implement later"
|
||
- [x] 无 "Add appropriate error handling" 等模糊描述
|
||
- [x] 每个步骤都有具体代码
|
||
- [x] 文件路径准确
|
||
- [x] 类型一致性检查通过
|
||
|
||
---
|
||
|
||
**计划完成!**
|
||
|
||
保存路径:`docs/superpowers/plans/2026-06-07-multi-login-social-login-plan.md`
|
||
|
||
**执行选项:**
|
||
|
||
1. **Subagent-Driven(推荐)** - 我为每个任务分派独立的子代理,任务间审查,快速迭代
|
||
2. **Inline Execution** - 在本会话中执行任务,批量执行并设置检查点
|
||
|
||
**请选择执行方式?**
|