Python · 6 分钟阅读
Flask 中的 GitHub OAuth 登录
目录
- 1. 先决条件
- 2. 安装依赖
- 3. 项目结构
- 4. 配置文件
- 5. GitHub OAuth 封装
- 6. 用户模型 + Flask-Login
- 7. 视图(路由)
- 8. 应用工厂
- 9. 模板
- 10. 运行
- 11. 最佳实践与安全清单
- 12. 常见问题
- 13. 进一步阅读
本章实现一个最小可用的 GitHub OAuth2 登录示例:
- 用户点击“用 GitHub 登录”被重定向到 GitHub
- 用户授权后回到
/callback - 后端用授权码换取 access token,拉取用户信息并存入会话
- 通过
Flask-Login维持登录状态 - 加入 state 参数抵御 CSRF,并通过
session保护 token
生产环境请额外考虑:scope 最小化、token 加密存储、刷新策略、用户表关联、审计日志
等。本章只展示最小骨架。
1. 先决条件
- Python ≥ 3.10
- Flask ≥ 3.0
- 一个 GitHub 账号
1.1 创建 GitHub OAuth App
- 登录 GitHub →
Settings→Developer settings→OAuth Apps→New OAuth App - 填写:
- Application name:你的应用名
- Homepage URL:
http://localhost:5000 - Authorization callback URL:
http://localhost:5000/callback
- 创建后保存 Client ID 并生成 Client Secret。
注意:本地回调地址必须与 GitHub 后台填写的完全一致,包括端口、协议、
末尾是否带斜杠。
2. 安装依赖
pip install -U flask flask-login requests
依赖说明:
- flask:Web 框架
- flask-login:管理登录态(
current_user、login_required等) - requests:调用 GitHub OAuth / API
- 强烈建议再加一个用于加解密 token 的库(生产环境):
cryptography
3. 项目结构
gh-oauth-demo/
├── app.py
├── oauth/
│ ├── __init__.py
│ ├── github.py # GitHub OAuth 客户端封装
│ └── routes.py # /login、/callback、/logout 视图
├── templates/
│ ├── base.html
│ └── index.html
├── .env # 本地敏感配置(不要提交到 git)
└── requirements.txt
4. 配置文件
# .env
FLASK_SECRET_KEY=please-generate-a-random-32-byte-string
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret
GITHUB_REDIRECT_URI=http://localhost:5000/callback
# config.py
from __future__ import annotations
from os import environ
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = environ["FLASK_SECRET_KEY"] # 用于签名 session
GITHUB_CLIENT_ID = environ["GITHUB_CLIENT_ID"]
GITHUB_CLIENT_SECRET = environ["GITHUB_CLIENT_SECRET"]
GITHUB_REDIRECT_URI = environ["GITHUB_REDIRECT_URI"]
SESSION_COOKIE_SECURE = True # 生产必须 HTTPS
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
pip install python-dotenv
5. GitHub OAuth 封装
把网络请求、URL 拼接、异常处理都收敛到 oauth/github.py 中:
# oauth/github.py
"""GitHub OAuth2 客户端封装。"""
from __future__ import annotations
from dataclasses import dataclass
from secrets import token_urlsafe
from typing import Any
from urllib.parse import urlencode
import requests
from flask import current_app
AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
TOKEN_URL = "https://github.com/login/oauth/access_token"
USER_URL = "https://api.github.com/user"
EMAILS_URL = "https://api.github.com/user/emails"
TIMEOUT = 5 # 秒
@dataclass(frozen=True)
class GithubUser:
id: int
login: str
name: str
email: str
avatar_url: str
def build_authorize_url(state: str) -> str:
"""拼接 GitHub 授权页 URL,state 用于防 CSRF。"""
params = {
"client_id": current_app.config["GITHUB_CLIENT_ID"],
"redirect_uri": current_app.config["GITHUB_REDIRECT_URI"],
"scope": "read:user user:email", # 最小 scope
"state": state,
"allow_signup": "true",
}
return f"{AUTHORIZE_URL}?{urlencode(params)}"
def make_state() -> str:
"""生成一次性 state,存入 session。"""
return token_urlsafe(32)
def exchange_code_for_token(code: str) -> str:
"""用授权码换 access token。"""
resp = requests.post(
TOKEN_URL,
data={
"client_id": current_app.config["GITHUB_CLIENT_ID"],
"client_secret": current_app.config["GITHUB_CLIENT_SECRET"],
"code": code,
"redirect_uri": current_app.config["GITHUB_REDIRECT_URI"],
},
headers={"Accept": "application/json"},
timeout=TIMEOUT,
)
resp.raise_for_status()
payload = resp.json()
token = payload.get("access_token")
if not token:
raise RuntimeError(f"换取 token 失败:{payload}")
return token
def fetch_user(token: str) -> GithubUser:
"""拉取 GitHub 用户基本信息。"""
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
user_resp = requests.get(USER_URL, headers=headers, timeout=TIMEOUT)
user_resp.raise_for_status()
data: dict[str, Any] = user_resp.json()
# 邮箱默认不公开,单独再请求一次
email = data.get("email") or ""
if not email:
email_resp = requests.get(EMAILS_URL, headers=headers, timeout=TIMEOUT)
if email_resp.ok:
for item in email_resp.json():
if item.get("primary") and item.get("verified"):
email = item["email"]
break
return GithubUser(
id=data["id"],
login=data["login"],
name=data.get("name") or data["login"],
email=email,
avatar_url=data.get("avatar_url", ""),
)
要点:
- state 是抵御 OAuth CSRF 的关键:登录时生成随机串写进 session,回调时校验。
- scope 按需申请:
read:user拿基础资料,user:email才能拿到邮箱。 - 超时 必设,避免第三方接口挂起拖死你的服务。
- 统一用
Bearer {token},是 GitHub 现在推荐的鉴权头格式(旧的token仍兼容但已弃用)。
6. 用户模型 + Flask-Login
为了演示得尽量短,这里把用户存进内存。生产请用数据库(Flask-SQLAlchemy 等)。
# oauth/__init__.py
from flask_login import LoginManager, UserMixin
login_manager = LoginManager()
class User(UserMixin):
"""最小用户模型,仅作演示。"""
def __init__(self, github_user):
self.id = str(github_user.id)
self.login = github_user.login
self.name = github_user.name
self.email = github_user.email
self.avatar_url = github_user.avatar_url
def __repr__(self) -> str:
return f"<User {self.login}>"
UserMixin 让 User 直接拥有 is_authenticated、is_active 等属性,
Flask-Login 会用得到。
7. 视图(路由)
# oauth/routes.py
from flask import Blueprint, abort, current_app, redirect, render_template, request, session, url_for
from flask_login import current_user, login_user, logout_user
from . import User
from .github import (
build_authorize_url,
exchange_code_for_token,
fetch_user,
make_state,
)
bp = Blueprint("auth", __name__)
@bp.get("/")
def index():
return render_template("index.html")
@bp.get("/login")
def login():
"""生成 state 并跳转到 GitHub。"""
state = make_state()
session["oauth_state"] = state
return redirect(build_authorize_url(state))
@bp.get("/callback")
def callback():
"""GitHub 授权回调。"""
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error:
return f"GitHub 拒绝授权:{error}", 400
expected = session.pop("oauth_state", None)
if not state or not expected or state != expected:
abort(400, "state 不匹配,可能是 CSRF 攻击")
if not code:
abort(400, "缺少授权码")
try:
token = exchange_code_for_token(code)
github_user = fetch_user(token)
except Exception as exc: # noqa: BLE001
current_app.logger.exception("OAuth 失败")
abort(500, f"OAuth 失败:{exc}")
# 真实项目:在这里把 token 加密后存数据库
session["github_access_token"] = token
user = User(github_user)
login_user(user)
return redirect(url_for("auth.index"))
@bp.get("/logout")
def logout():
logout_user()
session.pop("github_access_token", None)
return redirect(url_for("auth.index"))
8. 应用工厂
# app.py
from flask import Flask, render_template
from flask_login import current_user, login_required
from config import Config
from oauth import User, login_manager
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_object(Config)
app.config["SESSION_COOKIE_SECURE"] = not app.debug # dev 关,生产开
# 初始化扩展
login_manager.init_app(app)
login_manager.login_view = "auth.login"
# 注册蓝图
from oauth.routes import bp as auth_bp
app.register_blueprint(auth_bp)
@login_manager.user_loader
def load_user(user_id: str):
# 真实项目:在这里查数据库
return None # 演示:刷新页面就会“掉登录”
return app
app = create_app()
@app.get("/me")
@login_required
def me():
return {
"id": current_user.id,
"login": current_user.login,
"name": current_user.name,
"email": current_user.email,
"avatar": current_user.avatar_url,
}
if __name__ == "__main__":
app.run(debug=True)
9. 模板
{# templates/index.html #}
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>首页</title></head>
<body>
<h1>GitHub OAuth Demo</h1>
{% if current_user.is_authenticated %}
<p>欢迎,{{ current_user.name }}!</p>
<p><a href="{{ url_for('me') }}">查看我的资料 (JSON)</a></p>
<p><a href="{{ url_for('auth.logout') }}">退出登录</a></p>
{% else %}
<p><a href="{{ url_for('auth.login') }}">用 GitHub 登录</a></p>
{% endif %}
</body>
</html>
10. 运行
flask --app app run --debug
打开 http://127.0.0.1:5000,点击 “用 GitHub 登录”,授权后会跳回首页并显示
用户名。/me 返回当前用户 JSON 信息。
11. 最佳实践与安全清单
- state 必加:防止攻击者伪造回调。上面已示范。
- PKCE(推荐):公共客户端(无 secret)应使用 PKCE;服务端仍可选用。
- scope 最小化:只申请你真正需要的权限。
read:user user:email已经够用。 - Token 加密存储:直接
session["github_access_token"] = token适合演示;
生产请用对称加密(cryptography.fernet)后落库。 - HTTPS-only cookie:
SESSION_COOKIE_SECURE = True、
SESSION_COOKIE_HTTPONLY = True、SESSION_COOKIE_SAMESITE = "Lax"。 - 回调地址白名单:校验
redirect_uri与配置完全一致(GitHub 后台已经校验)。 - 错误处理:对网络异常、JSON 解析失败、用户拒绝授权等情况分别给出友好提示。
- 日志与审计:记录谁在何时用哪个 IP 登录,敏感字段打码。
- 可观测性:把
current_app.logger接入集中日志系统。 - 退出登录:调用
logout_user()+ 清理session,并按需调用
https://api.github.com/applications/{client_id}/token撤销 token。
12. 常见问题
- “The redirect_uri MUST match the registered callback URL” —— 回调地址
跟 GitHub 后台配置不一致。逐字符对比,包括协议、端口、末尾斜杠。 - 登录后页面刷新就掉线 —— 本教程
load_user返回None,仅供演示。
真实项目换成查数据库。 - 拿到
400 Bad credentials—— Client ID / Secret 配错,或 token 已过期。 - 拿不到邮箱 —— 用户在 GitHub 隐私设置里把邮箱设为私密,且你没有申请
user:emailscope。 - 想支持多 provider(GitHub / Google / GitLab) —— 把上面
oauth/github.py
抽象成OAuthProvider基类,每个 provider 各自实现build_authorize_url、
exchange_code_for_token、fetch_user即可。