Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ on:
options: [prod, dev]
push:
branches:
- master
- ci/*
- master
- feature/*
tags:
- 'v*'

Expand Down
4 changes: 2 additions & 2 deletions collect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ echo "==> 打包环境 [$ENV_NAME]"

# 清理并创建部署目录骨架
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR/conf" "$STAGE_DIR/module" "$STAGE_DIR/lib" "$STAGE_DIR/logs"
mkdir -p "$STAGE_DIR/conf" "$STAGE_DIR/module" "$STAGE_DIR/lib"

# 1. 渲染配置到 dist/conf(WORK_PATH 等取自 <ENV>.env)
OUT_CONF_DIR="$STAGE_DIR/conf" "$ROOT_DIR/switch-env.sh" "$ENV_NAME"
Expand All @@ -93,7 +93,7 @@ done
cp "$CHEN_SDK_LIB" "$STAGE_DIR/lib/"

# 4. 拷贝字典与错误码
cp -R "$ROOT_DIR/dict" "$STAGE_DIR/dict"
#cp -R "$ROOT_DIR/dict" "$STAGE_DIR/dict"
cp "$ROOT_DIR/errors.json" "$STAGE_DIR/errors.json"

# 5. 打包
Expand Down
115 changes: 115 additions & 0 deletions docs/github-oauth-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# GitHub 第三方登录实现

## 整体结构

整个功能很内聚,只有四个部分:**两个接口、一个绑定管理器、一张绑定表**。

- **入口接口** `/api/v1/user/oauth/github`:负责把用户送到 GitHub 授权页。
- **回调接口** `/api/v1/user/oauth/github/callback`:负责换 token、拉用户、建立本地登录态。
- **绑定管理器**:负责第三方身份与本地账号的映射查询和写入。
- **绑定表**:记录 `provider + open_id → user_id` 的对应关系。

两个接口都是**免登录**的公开接口——因为回调必须发生在本地会话建立之前,这是必然的。

## 一、配置

一共四项配置,通过环境变量注入:

| 配置 | 作用 |
|------|------|
| `client_id` | 拼授权 URL、换 token |
| `client_secret` | 只在服务端换 token 时使用 |
| `callback_url` | 授权和换 token 都要带,两边必须完全一致 |
| `frontend_base` | 登录完成后跳回前端的基址 |

配置走的是模板 + 环境变量替换的流程:YAML 模板里放占位符,部署时根据 dev / prod 环境注入真实值,脚本做替换,占位符没被替换会直接失败。生产环境的 `client_secret` 由 CI 从仓库密钥注入,不落代码库。

开发环境的 `callback_url` 指向本地前端端口,由前端开发服务器把 `/api` 同源代理到后端,这样回调地址和线上保持一致,不需要为本地单独注册。

## 二、登录流程

### 第一段:入口

1. 用户点击"用 GitHub 登录",请求入口接口,可以带一个登录后要跳回的前端路径。
2. 这个跳转路径会做**规范化和白名单校验**:只接受站内的相对路径,协议相对地址(`//` 开头)一律拒绝,非法值回退到默认首页——这是一道开放重定向防护。
3. 生成一个随机 `state`,把它和跳转路径一起写进 Redis,**设置 10 分钟过期**。注意 Redis 里存的是跳转目标,不是用户身份。
4. 拼出 GitHub 授权地址,带上 `client_id`、`callback_url`、最小 scope(`read:user user:email`)和 `state`,然后 302 跳过去。

### 第二段:回调

1. GitHub 带着 `code` 和 `state` 回调回来。
2. **校验并消费 state**:从 Redis 取出对应记录,取到后**立刻删除**。取不到就是无效、过期或重放,直接失败。删除动作保证了 state 只能被消费一次。
3. 用 `code` 向 GitHub 换 `access_token`,请求带 `Accept: application/json`,只认返回里非空的 token。
4. 拿 token 拉用户资料:先取 GitHub 的数字 `id`(作为后续唯一标识)、`login`、`name`、`email`、`avatar`;如果 `email` 为空,再单独拉一次邮箱列表,优先取"主邮箱且已验证"的那条。
5. 所有对 GitHub 的请求统一走一个带重试的封装:最多三次、30 秒超时,**只在连接失败/超时时退避重试**,业务解析失败不重试。

### 第三段:账号解析

按顺序尝试,命中即止:

1. **已有绑定**:用 `(provider=github, open_id)` 查绑定表,找到就取对应的本地用户。
2. **邮箱自动关联**:没有绑定但 GitHub 返回了邮箱时,用邮箱找本地已有账号,找到就把 GitHub 身份绑上去。
3. **创建新用户**:都没有就新建。
- 邮箱缺失时用一个 GitHub 专用占位邮箱兜底,建号前再查一次防止重复。
- 账号名优先用 GitHub `login`,冲突或非法时回退到 `gh_<id>` 并加后缀去重;显示名同理。
- 头像、GitHub 主页链接一并写入。
- **密码字段塞一个随机不可用的哈希**,保证这个账号永远无法用密码登录,避免意外。
- 角色默认普通用户,状态为激活。
4. 最后做一次兜底校验:用户必须存在、状态激活、未被删除,否则拒绝。

### 第四段:建立本地登录态

这一段**刻意复用普通密码登录的逻辑**,唯一区别是 **GitHub 登录跳过两步验证**:

1. 从系统设置读会话时长(默认 24 小时),生成一个随机 token。
2. 解析设备信息:User-Agent 判断平台、真实客户端 IP、设备 ID(优先请求头,其次 Cookie,都没有就新生成并写一个长期 Cookie)。
3. **单点登录控制**:如果设备已被其他用户占用就踢掉;同一用户在同一平台的旧会话也会被踢。最终允许"一个移动端 + 一个 PC 端"两个并发位。
4. 记录一条登录设备记录,把 token 缓存到 Redis。
5. 更新用户最近登录时间。
6. 设置 Cookie(用户标识、token、过期时间)和会话数据。

### 第五段:最终跳转

全部成功后,跳回 `frontend_base` 加上之前存的跳转路径,并带上成功标记;失败则跳到前端错误页,用错误码作为参数让前端展示。

## 三、数据模型

| 表 | 存什么 |
|----|--------|
| 用户表 | 本地账号主体,账号/昵称/邮箱唯一,含头像、GitHub 链接、角色状态等 |
| 绑定表 | `provider + open_id` 唯一,记录第三方身份与本地用户的映射和登录名 |
| 登录设备表 | 会话 token、设备、平台、IP、过期时间、是否活跃等 |

有一点值得强调:**GitHub 的 access_token 全程不落库**,只在回调请求内临时使用,用完即弃,也不做刷新令牌存储。长期保存的 token 是**我们自己站点**的会话 token,不是 GitHub 的。

## 四、后续请求如何鉴权

我们**没有用 JWT**,而是"服务端会话 + 每设备 token",由 Redis 和 MySQL 共同支撑。

每个请求进来时,框架统一做登录初始化(登录、登出这两个路径除外):

1. 会话里已有用户就直接校验 token 并刷新缓存。
2. 否则用 Cookie 自动登录:解密用户标识、检查 token 和有效期、确认账号激活、校验 token(先查 Redis,失败回落数据库)。
3. 校验通过就把用户写回会话。

需要登录的接口在这个基础上再包一层,初始化失败就直接返回"未登录"。

token 校验是双通道的:先看 Redis,再回落数据库行(要求活跃、用户匹配、未过期)。**踢人**就是把设备标记为不活跃并删除 Redis 里的 token。此外还有独立的**刷新 token** 和**登出**接口,刷新会轮换 token 并重设 Cookie,登出则清理设备和会话。

## 五、错误码

只有两个 GitHub 专属错误码:

- **GitHub OAuth 失败**:未配置、换 token 失败或拉资料失败。
- **state 无效**:`code`/`state` 缺失、过期或重放。

其余复用通用错误码(用户不存在、账号异常、数据库操作失败)。回调阶段的错误会以 `oauth_error` 参数带给前端展示。

## 小结

一句话概括这套实现的设计取舍:

- **最小权限 + state 一次性消费 + 跳转白名单** 三道安全防线;
- **GitHub token 即用即弃**,登录态完全自建,与 GitHub 解耦;
- **绑定 → 邮箱关联 → 新建** 三级账号解析,兼顾老用户和自动关联;
- **复用密码登录的会话体系**,只额外跳过 2FA,保持鉴权逻辑单一。
3 changes: 3 additions & 0 deletions docs/github-pr-sync-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@

手动全量同步接口 `/api/v1/organization/repos/prs/sync` 保持全量行为不变。

传 `id`(本地 PR 记录 id)时走单条同步:拉取该 PR 详情与 reviews 后回写,
GitHub 404 / 限流等错误直接返回非零 errno;传 `repo_id` 时仍为异步全量同步。

## 问题二:PR insert 失败

### 现象
Expand Down
6 changes: 6 additions & 0 deletions errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@
{ "errno": 5017, "key": "ORG_USER_REL_FAILED", "zh": "组织成员操作失败", "en": "Organization member operation failed" },
{ "errno": 5018, "key": "ORG_REPO_NOT_FOUND", "zh": "仓库不存在", "en": "Repository not found" },
{ "errno": 5019, "key": "ORG_REPO_NAME_EXISTS", "zh": "该组织下已存在同名仓库", "en": "Repository name already exists in this organization" },
{ "errno": 5020, "key": "ORG_PR_NOT_FOUND", "zh": "PR 记录不存在", "en": "PR record not found" },
{ "errno": 5021, "key": "ORG_REPO_TOKEN_MISSING", "zh": "仓库未配置 GitHub Token", "en": "Repository GitHub token not configured" },
{ "errno": 5022, "key": "GITHUB_PR_NOT_FOUND", "zh": "GitHub 上未找到该 PR", "en": "PR not found on GitHub" },
{ "errno": 5023, "key": "GITHUB_RATE_LIMITED", "zh": "GitHub API 触发限流,请稍后再试", "en": "GitHub API rate limit exceeded, please try again later" },
{ "errno": 5024, "key": "GITHUB_API_FAILED", "zh": "GitHub API 请求失败", "en": "GitHub API request failed" },
{ "errno": 5025, "key": "ORG_PR_SYNC_IN_PROGRESS", "zh": "该 PR 正在同步中,请稍后", "en": "PR sync already in progress, please wait" },

{ "errno": 6001, "key": "ASSIGNMENT_NOT_FOUND", "zh": "作业不存在", "en": "Assignment not found" },
{ "errno": 6002, "key": "ASSIGNMENT_INVALID_ID", "zh": "作业ID无效", "en": "Invalid assignment ID" },
Expand Down
1 change: 1 addition & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ case "$MODE" in
log "热更新模块: $tmp/module/*.so -> $DEPLOY_PATH/module/"
mkdir -p "$DEPLOY_PATH/module"
mv "$tmp"/module/*.so "$DEPLOY_PATH/module/"
mv "$tmp/errors.json" "$DEPLOY_PATH/errors.json"

# 优先向 master 发 SIGHUP(触发 master fork 新 worker,不依赖 pid 文件/work_path)
master="$(server_master_pid || true)"
Expand Down
6 changes: 6 additions & 0 deletions src/error_codes.h
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ constexpr int32_t ORG_APPLY_UPDATE_FAILED = 5016; // 更新申请失败
constexpr int32_t ORG_USER_REL_FAILED = 5017; // 组织成员操作失败
constexpr int32_t ORG_REPO_NOT_FOUND = 5018; // 仓库不存在
constexpr int32_t ORG_REPO_NAME_EXISTS = 5019; // 同组织下仓库名已存在
constexpr int32_t ORG_PR_NOT_FOUND = 5020; // PR 记录不存在
constexpr int32_t ORG_REPO_TOKEN_MISSING = 5021; // 仓库未配置 GitHub Token
constexpr int32_t GITHUB_PR_NOT_FOUND = 5022; // GitHub 上未找到该 PR
constexpr int32_t GITHUB_RATE_LIMITED = 5023; // GitHub API 触发限流
constexpr int32_t GITHUB_API_FAILED = 5024; // GitHub API 请求失败
constexpr int32_t ORG_PR_SYNC_IN_PROGRESS = 5025; // 该 PR 正在同步中

// ==================== 模块06: 作业模块 (06001-06999) ====================
constexpr int32_t ASSIGNMENT_NOT_FOUND = 6001; // 作业不存在
Expand Down
Loading
Loading