接入 Github OAuth 2.0

开发自己的 OAuth 服务之前,通过集成第三方的 OAuth 服务能更加了解其流程,本章我们将通过接入 Github 的 OAuth 服务来体验 OAuth 的完整流程。

流程总览

接入 Github OAuth 2.0 的流程可以概括为:

  1. 申请接入
  2. 接受用户授权
  3. 业务处理

申请接入

首先,通过申请页面,提交表单:

注册Github OAuth 应用

  • Application name:应用名称,比如 AXUM中文网
  • Homepage URL:主页地址,比如 https://axum.eu.org
  • Application description:应用描述,比如 AXUM中文网为你提供了企业级axum Web开发中所需要的大部分知识。从基础知识到企业级项目的开发,都有完整的系列教程。更难得的是,除了文字教程,我们还录制了配套的视频教程,方便你以多种形式进行学习。
  • Authorization callback URL:用户授权后的回调页面,比如 https://axum.eu.org/oauth/github

Github OAuth 允许填写本地 URL,这在开发时非常有用。比如,开发时可以将:

  • Homepage URL 设置为 http://127.0.0.1
  • Authorization callback URL 设置为 http://127.0.0.1/oauth/github

注册成功后,将会进入管理页面,如下图:

Github OAuth 管理

首次进入该页面,你需要点击 【Generate a new client secret】按钮,生成一个新的密钥。该页面有2个数据至关重要:

  • Client ID:你的应用的客户ID
  • Client secret:你的应用的密钥。该密钥只在生成时显示一次,所以注意保存。
async fn index() -> Html<String> {
    let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap();
    let html_content = format!(
        r#"<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>OAuth 2.0</title>
        </head>
        <body>
            <div style="margin: 5rem">
                <a href="https://github.com/login/oauth/authorize?scope=user:email&client_id={client_id}" style="background: blueviolet; border-radius: 0.5rem; color: #fff; padding: 0.5rem;text-decoration: none;">使用 Github 登录</a>
            </div>
        </body>
        </html>
        "#,
    );
    Html(html_content.to_string())
}
  • 只要将链接地址指向 https://github.com/login/oauth/authorize?scope=【作用域】&client_id=【客户ID】即可

效果如图:

Github 登录链接

当用户点击绿色的【Authorize】按钮后,完成用户授权,Github 会跳转到申请接入时填写的【Authorization callback URL】页面。

处理授权

下面我们来编写【Authorization callback URL】页面(一下简称“回调页面”),以便处理授权。

Github 会将临时的授权码通过 URL 的 code 参数返回给回调页面,所以,我们要在 AXUM 中获取这个参数:

async fn callback(Query(q): Query<HashMap<String, String>>) {
    let code = q.get("code").unwrap();
    println!("callback: {code}");
}

这只是一个临时码,我们要通过这个临时码向 Github 服务器换取正式的访问令牌(Access Token):

https://github.com/login/oauth/access_token 发起 POST 请求,参数为:

参数说明
client_id客户ID
client_secret 密钥
code上一步获取的临时码

如果要让 Github 返回 JSON 格式的数据,需要在请求头插入 Accept: application/json。请求成功后,Github 会返回如下字段:

字段说明
access_token访问令牌
scope作用域
token_type令牌类型

针对此,我们可以定义一个结构体:

#[derive(Debug, Serialize, Deserialize)]
pub struct AccessTokenRespose {
    pub access_token: String,
    pub scope: String,
    pub token_type: String,
}

示例数据:

AccessTokenRespose { access_token: "gho_BHsPm9*****1CrG0Opv1u", scope: "user:email", token_type: "bearer" }

我们来看一下该函数的完整实现:

async fn callback(Query(q): Query<HashMap<String, String>>) {
    let code = q.get("code").unwrap();

    let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap();
    let secert_key = std::env::var("GITHUB_SECERT_KEY").unwrap();

    let mut headers = reqwest::header::HeaderMap::new();
    headers.insert(
        reqwest::header::ACCEPT,
        reqwest::header::HeaderValue::from_static("application/json"),
    );

    let cli = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .unwrap();

    let mut data = HashMap::new();
    data.insert("client_id", client_id);
    data.insert("client_secret", secert_key);
    data.insert("code", code.into());

    let resp: AccessTokenRespose = cli
        .post("https://github.com/login/oauth/access_token")
        .json(&data)
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    println!("{resp:?}");
}

获取更多信息和作用域

拿到了访问令牌(Access Token)后,我们可以向 Github 获取更多信息,比如获取用户邮箱、头像等。

不同的信息由作用域决定,官方文档列举了可用的作用域,而这里列出了非常丰富的 API。

下面以获取已授权用户信息为例进行演示:

async fn user_info(Path((access_token, token_type)): Path<(String, String)>) -> String {
    let auth = format!("{token_type} {access_token}");

    let mut headers = reqwest::header::HeaderMap::new();
    headers.insert(
        reqwest::header::ACCEPT,
        reqwest::header::HeaderValue::from_static("application/json"),
    );
    headers.insert(
        reqwest::header::AUTHORIZATION,
        reqwest::header::HeaderValue::from_str(&auth).unwrap(), // 1️⃣
    );
    headers.insert(
        reqwest::header::USER_AGENT,
        reqwest::header::HeaderValue::from_static("AXUM.EU.ORG"), // 2️⃣
    );

    let cli = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .unwrap();
    let resp = cli
        .get("https://api.github.com/user") // 3️⃣
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    resp
}

注意:本示例可能需要将 scope 改为 userread:user

至此,我们已经成功接入 Github OAuth 了,回顾一下完整代码:

use std::collections::HashMap;

use axum::{
    Router,
    extract::{Path, Query},
    response::Html,
    routing::get,
    serve,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv::dotenv().ok();

    let listener = TcpListener::bind("127.0.0.1:9527").await?;

    let app = Router::new()
        .route("/", get(index))
        .route("/callback", get(callback))
        .route("/user-info/{access_token}/{token_type}", get(user_info));

    serve(listener, app).await?;
    Ok(())
}

async fn index() -> Html<String> {
    let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap();
    let html_content = format!(
        r#"<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>OAuth 2.0</title>
        </head>
        <body>
            <div style="margin: 5rem">
                <a href="https://github.com/login/oauth/authorize?scope=read:user&client_id={client_id}" style="background: blueviolet; border-radius: 0.5rem; color: #fff; padding: 0.5rem;text-decoration: none;">使用 Github 登录</a>
            </div>
        </body>
        </html>
        "#,
    );
    Html(html_content.to_string())
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AccessTokenRespose {
    pub access_token: String,
    pub scope: String,
    pub token_type: String,
}
async fn callback(Query(q): Query<HashMap<String, String>>) -> Html<String> {
    let code = q.get("code").unwrap();

    let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap();
    let secert_key = std::env::var("GITHUB_SECERT_KEY").unwrap();

    let mut headers = reqwest::header::HeaderMap::new();
    headers.insert(
        reqwest::header::ACCEPT,
        reqwest::header::HeaderValue::from_static("application/json"),
    );

    let cli = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .unwrap();

    let mut data = HashMap::new();
    data.insert("client_id", client_id);
    data.insert("client_secret", secert_key);
    data.insert("code", code.into());

    let resp: AccessTokenRespose = cli
        .post("https://github.com/login/oauth/access_token")
        .json(&data)
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    println!("{resp:?}");

    Html(format!(
        r#"<a href="/user-info/{}/{}">用户信息</a>"#,
        &resp.access_token, &resp.token_type
    ))
}

async fn user_info(Path((access_token, token_type)): Path<(String, String)>) -> String {
    let auth = format!("{token_type} {access_token}");

    let mut headers = reqwest::header::HeaderMap::new();
    headers.insert(
        reqwest::header::ACCEPT,
        reqwest::header::HeaderValue::from_static("application/json"),
    );
    headers.insert(
        reqwest::header::AUTHORIZATION,
        reqwest::header::HeaderValue::from_str(&auth).unwrap(),
    );
    headers.insert(
        reqwest::header::USER_AGENT,
        reqwest::header::HeaderValue::from_static("AXUM.EU.ORG"),
    );

    let cli = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .unwrap();
    let resp = cli
        .get("https://api.github.com/user")
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    resp
}

本章目的是让读者了解 Github OAuth 接入流程,实际开发中,读者可以使用现成的 Crate 来加速开发。

本章代码位于01.github-oauth分支。

要查看完整内容,请先登录