"用GitHub账号登录"是每个网站都想要的体验:用户不用注册、密码永远不会经过你的服务器。
本篇走完授权码流程全链路 ,并把JWT接进来------OAuth负责 "他是谁",JWT负责 "他还在"。
🎯 本篇产出:GitHub登录入口 + 回调处理 + 自动建本地用户 + 签发自有JWT。含代码约110行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | 给早报站加上GitHub第三方登录 |
| 代码行数 | ~110行(含注释) |
| 依赖 | requests(第11篇老朋友) |
| 核心功能 | 授权码流程 + find-or-create + 签发自有JWT |
| 跑起来的命令 | /docs → GET /auth/github → 浏览器走流程 |
| 核心知识点 | OAuth授权码六步、client_secret只走服务器、find-or-create |
| 做完你能得到 | 一套标准的第三方登录方案,换平台只改端点 |
🚨 核心认知 :OAuth只是 "认证",进了门之后发什么令牌、给什么权限,完全是你自己说了算。
一、先想清楚:OAuth和JWT的分工
JWT是 "自己发令牌 ":你的站有自己的用户体系,登录后你发令牌。
OAuth是 "别人帮你认证":GitHub确认了 "这人是谁",然后把这个结论告诉你。
🎯 两者不是二选一,而是组合
标准姿势:

📌 一次OAuth换一张长期有效的 "本地通行证",这是全世界的标准做法。
二、授权码流程:六步走(核心概念课)
主角有三个:用户(浏览器)、你的站(Client)、GitHub(授权服务器)。
📋 六步全流程

🚨 两个必须记住的安全点
| # | 安全点 | 说明 |
|---|---|---|
| ① | 第④步必须在服务器做 | 换token需要client_secret------它和你的JWT_SECRET同级别,永远只活在服务器和.env里 |
| ② | state参数防CSRF | 攻击者可以伪造 "回调你的服务器"------带一个自己生成的state,回调时比对,对不上就拒绝(本篇先演示流程,state完整校验放练习①) |
三、第0步:注册OAuth App
要让你的早报站支持"用GitHub登录",首先得在GitHub上注册一个OAuth App------这相当于你在GitHub那里给早报站开了一个"官方认证通道"。
📍 第1步:进入Developer settings
- 登录GitHub,点击右上角你的头像;
- 在下拉菜单中点击Settings(设置);
- 在左侧边栏一直往下拉,找到Developer settings(开发者设置)并点击。
💡 找不到? 直接在浏览器地址栏输入
https://github.com/settings/developers也可以直达。
📍 第2步:进入OAuth Apps
在Developer settings页面,左侧边栏有三个选项,点击OAuth Apps 。
⚠️ 注意 :这里选 OAuth Apps ,不是GitHub Apps。
两者都基于OAuth 2.0,但GitHub Apps的权限模型更细粒度,配置也更复杂。
本专栏教学场景用OAuth Apps最简单、最直接。
📍 第3步:新建OAuth App
点击右上角的New OAuth App按钮。
💡 如果你从没创建过任何App :这个按钮会显示为 Register a new application(注册新应用)------意思完全一样,点它就行。
📍 第4步:填写应用信息(关键步骤)
这是最容易出错的一步,每个字段都要仔细填:
| 字段 | 填什么 | 说明 |
|---|---|---|
| Application name | Python早报(或你喜欢的名字) |
用户授权时会看到这个名字 |
| Homepage URL | http://localhost:8000 |
应用的"主页地址"。开发环境就填本地地址 |
| Application description | Python早报站的GitHub登录 |
可选,用户授权时看到的描述 |
| Authorization callback URL | http://localhost:8000/auth/github/callback |
🔴 最关键的一个字段------GitHub授权完成后会把用户"送回"这个地址 |
🔴 Authorization callback URL:为什么必须完全一致?
这个字段告诉GitHub:"用户同意授权后,把他重定向到我服务器的哪个地址"。
它必须和你代码里settings.github_redirect_uri的值逐字一致 ------端口、路径、localhost与127.0.0.1 的写法,差一个字符都会导致redirect_uri_mismatch错误。
⚠️ OAuth App只能设置一个回调URL ,不像GitHub Apps支持多个或通配符匹配。所以在开发阶段固定用
http://localhost:8000/auth/github/callback,上线时再改。
📍 第5步:注册应用
确认所有字段无误后,点击底部的 Register application(注册应用)按钮。
📍 第6步:获取Client ID和Client Secret
注册成功后,页面会自动跳转到该应用的设置页。你会看到两个关键凭证:
| 凭证 | 在哪里 | 性质 |
|---|---|---|
| Client ID | 页面顶部直接显示 | 公开的,可以出现在前端代码里 |
| Client Secret | 点击Generate a new client secret按钮生成 | 🔴 机密,只显示一次,必须立即复制保存 |
🚨 Client Secret只显示一次! 如果你关掉页面才发现没复制,只能重新生成一个------旧的立即作废。把它写进
.env文件,绝不进Git仓库。
📍 第7步:写入 .env
在项目根目录的.env文件里添加三行(如果.env不存在就新建,并确认它在.gitignore里):
text
GITHUB_CLIENT_ID=你的Client_ID
GITHUB_CLIENT_SECRET=你的Client_Secret
GITHUB_REDIRECT_URI=http://localhost:8000/auth/github/callback
💡 三处对齐检查 :
.env里的GITHUB_REDIRECT_URI、app/config.py里github_redirect_uri的默认值、以及GitHub OAuth App设置页里的 Authorization callback URL------这三处必须是同一个地址。
四、第1步:配置Settings
config.py加三个字段(.env自动注入):
python
class Settings(BaseSettings):
...
github_client_id: str = ""
github_client_secret: str = ""
github_redirect_uri: str = "http://localhost:8000/auth/github/callback"
五、第2步:发起登录------拼授权URL
app/routers/github.py:
python
"""app/routers/github.py ------ GitHub OAuth登录"""
import secrets
import urllib.parse
import requests
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.deps import get_session
from core.models import User
router = APIRouter(prefix="/auth/github", tags=["github"])
GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize"
GITHUB_TOKEN = "https://github.com/login/oauth/access_token"
GITHUB_USER = "https://api.github.com/user"
@router.get("")
def github_login():
"""① 生成授权URL(演示版直接返回URL;生产用302 + state存会话)"""
state = secrets.token_urlsafe(16) # 防伪造回调的随机串
params = {
"client_id": settings.github_client_id,
"redirect_uri": settings.github_redirect_uri,
"scope": "read:user", # 只申请读用户信息
"state": state,
}
url = f"{GITHUB_AUTHORIZE}?{urllib.parse.urlencode(params)}"
return {"login_url": url}
💡 OAuth全程就是三次HTTP请求,没有任何新技术。
六、第3步:回调------换令牌、取用户、建用户、签JWT
先把create_token抽到共享模块(避免两处重复定义):
app/security.py(新建,从auth.py里抽出来):
python
"""app/security.py ------ 令牌签发与校验(auth/github共用)"""
from datetime import datetime, timedelta, timezone
import jwt
from app.config import settings
from core.models import User
def create_token(user: User, expires_minutes: int, token_type: str) -> str:
"""签发令牌:payload只放身份标识和时间,不放敏感信息"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user.id),
"type": token_type,
"iat": now,
"exp": now + timedelta(minutes=expires_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
💡
auth.py改为from app.security import create_token,删掉重复定义。
app/routers/github.py 继续:
python
from app.security import create_token
@router.get("/callback")
def github_callback(
code: str,
state: str,
session: Session = Depends(get_session),
):
"""③④⑤⑥ 回调四连:换token → 取用户 → find-or-create → 签自己的JWT"""
# 演示版不校验state;完整校验见练习①
# ④ code换access_token
# ⚠️ GitHub的OAuth端点期望表单编码,必须用data=而不是json=
token_resp = requests.post(GITHUB_TOKEN, headers={
"Accept": "application/json",
}, data={
"client_id": settings.github_client_id,
"client_secret": settings.github_client_secret,
"code": code,
"redirect_uri": settings.github_redirect_uri,
}, timeout=10)
access_token = token_resp.json().get("access_token")
if not access_token:
raise HTTPException(status_code=401, detail="GitHub授权失败,请重试")
# ⑤ 取GitHub用户信息
me = requests.get(GITHUB_USER, headers={
"Authorization": f"Bearer {access_token}",
}, timeout=10).json()
# ⑥ find-or-create:按github_id找本地用户,没有就建
user = session.scalar(select(User).where(
User.github_id == str(me["id"])))
if user is None:
user = User(
username=f"gh_{me['login']}", # 前缀隔离,避免撞已有用户名
github_id=str(me["id"]),
)
session.add(user)
session.commit()
# 签发自己的JWT,从此走第06篇的体系
token = create_token(user, settings.jwt_expire_minutes, "access")
return {"access_token": token, "token_type": "bearer",
"username": user.username}
🔧 User模型加列(alembic第三次兑现)
python
class User(Base):
...
github_id: Mapped[str | None] = mapped_column(unique=True, default=None)
bash
alembic revision --autogenerate -m "add user github_id"
alembic upgrade head
📌 挂载路由
app/main.py 补一行:
python
from app.routers import articles, sources, auth, github
app.include_router(auth.router)
app.include_router(github.router)
七、验收清单
bash
1. /docs → GET /auth/github → 复制login_url到浏览器
2. 跳转GitHub授权页 → 确认授权 → 自动跳回localhost回调地址
3. 回调返回JSON:access_token + 新用户名gh_xxx
4. 用这个token调/auth/me → 能查到用户(JWT体系无缝衔接)
5. psql看users表:github_id已存,password_hash为空(OAuth用户无密码,正常)
6. 再次走全流程 → 返回同一个用户(find-or-create生效,不重复建号)
7. 全程无报错后提交Git
bash
git add .
git commit -m "GitHub OAuth登录:授权码流程 + find-or-create"
八、常见报错:这6个,OAuth的标配(重点!)
① 跳转授权页报redirect_uri_mismatch
🔍 原因 :注册回调地址与代码里的redirect_uri不完全一致------端口、路径、尾部斜杠差一个字符都不行。
✅ 解法 :两处逐字对齐。localhost与127.0.0.1也视为不同。
② 授权后回调报401或GitHub返回bad_verification_code
🔍 原因 :code是一次性的------刷新页面重复请求回调,code已作废。
✅ 解法:不是bug,重新发起登录即可。
📌 调试时记得每次走完整流程。
③ error=invalid_client_id / token换不到
🔍 原因:Client ID抄错,或Client Secret不是本App的(或.env没生效)。
✅ 解法 :回GitHub OAuth App页核对;重启uvicorn让.env重新加载(第05篇⑥)。
④ 用户名撞车:gh_xxx已存在,unique约束报错
🔍 原因:有人在你的站注册过同名用户,或重复建号竞态。
✅ 解法:
- username冲突时追加github_id后缀(
gh_xxx_12345); - 更稳的做法是用email做关联键(生产方案)。
⑤ 回调地址用127.0.0.1时,GitHub提示无法访问
🔍 原因 :GitHub对OAuth回调的localhost白名单有特殊处理------注册和代码必须统一用localhost或统一用127.0.0.1,混用必mismatch。
✅ 解法 :统一为localhost:8000。
⑥ Client Secret泄露了
🔍 原因 :可能进过git、贴过群聊------它是你 "代表这个App发言"的凭证。
✅ 解法:GitHub OAuth App页直接点 "Regenerate client secret"换新,旧的立即作废。
📌 密钥可重置,前提是发现得早。
九、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | state完整校验:发起登录时把state存进短期缓存(或签进2分钟JWT),回调时比对 | ⭐⭐⭐ | 伪造回调就此失效 |
| 2 | 头像入库 :me["avatar_url"]存进User |
⭐⭐ | 加列 + 迁移 + find-or-create赋值 |
| 3 | 邮箱关联 :scope加user:email,调/user/emails拿主邮箱,用它做find-or-create的键 |
⭐⭐⭐ | 生产级方案 |
| 4(选做) | 平台对比:看企业微信/飞书/微信开放平台的OAuth文档 | ⭐⭐ | 流程完全一样,只是换端点换字段------学会一个,全会 |
📦 配套代码
完整github.py、security.py与迁移脚本已上传Git(python_daily/):【gitee仓库地址】