“用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里):
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自动注入):
classSettings(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:
"""app/routers/github.py —— GitHub OAuth登录"""importsecretsimporturllib.parseimportrequestsfromfastapiimportAPIRouter,Depends,HTTPExceptionfromsqlalchemyimportselectfromsqlalchemy.ormimportSessionfromapp.configimportsettingsfromapp.depsimportget_sessionfromcore.modelsimportUser 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("")defgithub_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里抽出来):
"""app/security.py —— 令牌签发与校验(auth/github共用)"""fromdatetimeimportdatetime,timedelta,timezoneimportjwtfromapp.configimportsettingsfromcore.modelsimportUserdefcreate_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),}returnjwt.encode(payload,settings.jwt_secret,algorithm="HS256")💡
auth.py改为from app.security import create_token,删掉重复定义。
app/routers/github.py继续:
fromapp.securityimportcreate_token@router.get("/callback")defgithub_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")ifnotaccess_token:raiseHTTPException(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"])))ifuserisNone: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第三次兑现)
classUser(Base):...github_id:Mapped[str|None]=mapped_column(unique=True,default=None)alembic revision--autogenerate-m"add user github_id"alembic upgradehead📌 挂载路由
app/main.py补一行:
fromapp.routersimportarticles,sources,auth,github app.include_router(auth.router)app.include_router(github.router)七、验收清单
1. /docs → GET /auth/github → 复制login_url到浏览器2. 跳转GitHub授权页 → 确认授权 → 自动跳回localhost回调地址3. 回调返回JSON:access_token + 新用户名gh_xxx4. 用这个token调/auth/me → 能查到用户(JWT体系无缝衔接)5. psql看users表:github_id已存,password_hash为空(OAuth用户无密码,正常)6. 再次走全流程 → 返回同一个用户(find-or-create生效,不重复建号)7. 全程无报错后提交Gitgitadd.gitcommit-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仓库地址】