R / Richie全部文章 ↑

Python · 6 分钟阅读

Flask 中的 GitHub OAuth 登录

目录


本章实现一个最小可用的 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

  1. 登录 GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
  2. 填写:
    • Application name:你的应用名
    • Homepage URL:http://localhost:5000
    • Authorization callback URL:http://localhost:5000/callback
  3. 创建后保存 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:email scope。
  • 想支持多 provider(GitHub / Google / GitLab) —— 把上面 oauth/github.py
    抽象成 OAuthProvider 基类,每个 provider 各自实现 build_authorize_url、
    exchange_code_for_token、fetch_user 即可。

13. 进一步阅读