OAuth

OAuth 是一种用于“访问委托”的开放标准,通常用作互联网用户授权网站或应用程序访问其在其他网站上的信息,而无需提供密码的方式。它是在许多网站上实现“使用 Google 登录”的机制,让你不必再记住和管理又一个密码。与许多认证相关的主题一样,OAuth 标准有很深的内涵和复杂性,但一旦你理解了基本用法,它就可以成为一个非常方便的替代方案,取代自己管理用户账户。

在此页面上,你将了解如何使用 OAuth 和 FastHTML 来实现一些常见的功能。

创建一个客户端

FastHTML 提供了用于管理不同 OAuth 提供商设置和状态的客户端类。目前已实现的有:GoogleAppClient、GitHubAppClient、HuggingFaceClient 和 DiscordAppClient——如果需要添加其他提供商,请参阅源代码。你需要从提供商那里获取一个 client_idclient_secret(请参阅本页后面从零开始的示例,了解如何在 GitHub 注册)来创建客户端。我们建议将这些信息存储在环境变量中,而不是硬编码在你的代码里。

import os
from fasthtml.oauth import GoogleAppClient
client = GoogleAppClient(os.getenv("AUTH_CLIENT_ID"),
                         os.getenv("AUTH_CLIENT_SECRET"))

客户端用于获取登录链接,并管理你的应用与 OAuth 提供商之间的通信(client.login_link(redirect_uri="/redirect"))。

使用 OAuth 类

一旦你设置好客户端,向 FastHTML 应用添加 OAuth 就可以像下面这样简单:

from fasthtml.oauth import OAuth
from fasthtml.common import FastHTML, RedirectResponse

class Auth(OAuth):
    def get_auth(self, info, ident, session, state):
        email = info.email or ''
        if info.email_verified and email.split('@')[-1]=='answer.ai':
            return RedirectResponse('/', status_code=303)

app = FastHTML()
oauth = Auth(app, client)

@app.get('/')
def home(auth): return P('Logged in!'), A('Log out', href='/logout')

@app.get('/login')
def login(req): return Div(P("Not logged in"), A('Log in', href=oauth.login_link(req)))

这里发生了很多事情,让我们来解析一下代码中的内容。

  • OAuth(以及我们自定义的 Auth 类)有许多默认参数,包括一些关键的 URL:redir_path='/redirect', error_path='/error', logout_path='/logout', login_path='/login'。它会创建并处理重定向和登出路径,而你需要自己处理 /login(未成功登录的尝试将被重定向到此处)和 /error(用于处理 OAuth 错误)。
  • 当我们运行 oauth = Auth(app, client) 时,它会向应用添加重定向和登出路径,并添加一些“前置件”(beforeware)。这个前置件会在处理任何请求之前运行(除了通过 skip 参数指定的请求之外)。

添加的前置件指定了一些应用行为:

  • 在这里,如果一个未登录的用户尝试访问我们的主页(/),他们将被重定向到 /login
  • 如果他们已经登录,它会调用一个 check_invalid 方法。该方法默认返回 False,允许用户继续访问他们请求的页面。你可以通过在 Auth 类中定义自己的 check_invalid 方法来修改此行为——例如,你可以用它来强制登出最近被封禁的用户。

那么用户如何登录呢?如果他们访问(或被重定向到)/login 登录页面,我们会向他们展示一个登录链接。这个链接会将他们发送到 OAuth 提供商,在那里他们将完成选择账户、授予权限等步骤。完成后,他们将被重定向回 /redirect。在后台,作为他们请求一部分的代码会被转换成用户信息,然后传递给关键函数 get_auth(self, info, ident, session, state)。在这里,你可以处理在数据库中查找或添加用户、检查某个条件(例如,这段代码检查电子邮件是否为 answer.ai 的邮箱地址)或根据状态选择目标页面。参数如下:

  • self:Auth 对象,你可以用它来访问客户端(self.cli)。
  • info:由 OAuth 提供商提供的信息,通常包括唯一的用户 ID、电子邮件地址、用户名和其他元数据。
  • ident:该用户的唯一标识符。其形式因提供商而异。这对于管理用户数据库等非常有用。
  • session:当前会话,你可以在其中安全地存储信息。
  • state:你可以在创建登录链接时选择性地传入一些状态。这个状态会持续存在,并在用户完成 OAuth 步骤后返回,这对于将他们带回离开时的同一页面非常有用。它还可以用作额外的安全措施,以防止 CSRF 攻击。

在我们的示例中,我们检查了 info 中的电子邮件(我们使用的是 GoogleAppClient,并非所有提供商都会包含电子邮件)。如果我们不满意,并且 get_auth 返回 False 或 None(如此处非 answer.ai 用户的案例),那么用户将被重定向回登录页面。但如果一切正常,我们返回一个到主页的重定向,并且一个 auth 键会被添加到会话和包含用户身份 ident 的作用域中。因此,例如,在主页路由中,我们可以使用 auth 来查找这个特定用户的个人资料信息并相应地定制页面。这个 auth 将保留在他们的会话中,直到他们清除浏览器缓存,所以默认情况下他们会保持登录状态。要将他们登出,请移除它(session.pop('auth', None))或将他们发送到 /logout,后者会自动为你完成此操作。

通过从零开始的实现解释 OAuth

希望上面的例子足以让你入门。你也可以查看实现此功能的(相当精简的)源代码,以及这里的示例

如果你想更深入地了解其工作原理,并看看可以在哪里添加额外的功能,本页的其余部分将不使用 OAuth 便捷类来演示一些示例,以阐明其概念。这部分内容是在上述 OAuth 类可用之前编写的,保留在此用于教育目的——我们建议你在大多数情况下坚持使用上面展示的新方法。

一个最小化的登录流程 (GitHub)

让我们从构建一个最小化的“使用 GitHub 登录”流程开始。这将演示 OAuth 的基本步骤。

OAuth 需要一个“提供商”(在本例中是 GitHub)来验证用户。因此,设置我们应用的第一步是在 GitHub 上注册以完成配置。

前往 https://github.com/settings/developers 并点击“New OAuth App”。用以下值填写表单,然后点击“Register application”。

  • Application name: 你的应用名称
  • Homepage URL: https://:8000 (或你正在使用的任何 URL - 稍后可以更改)
  • Authorization callback URL: https://:8000/auth_redirect (稍后也可以修改)
Setting up an OAuth app in GitHub

注册后,你会看到一个屏幕,可以在其中查看客户端 ID 并生成一个客户端密钥。将这些值保存在安全的地方。你将使用它们在 FastHTML 中创建一个 GitHubAppClient 对象。

这个 client 对象负责处理 OAuth 流程中依赖于你的应用和 GitHub 之间直接通信的部分,而不是通过用户浏览器的重定向进行的交互。

以下是如何设置客户端对象:

client = GitHubAppClient(
    client_id="your_client_id",
    client_secret="your_client_secret"
)

你还应该保存你在注册时提供的授权回调 URL 的路径部分。

GitHub 将用户浏览器重定向到这个路由,以便向你的应用发送一个授权码。你应该只保存 URL 的路径部分,而不是整个 URL,因为你希望你的代码在部署时能自动工作,那时 URL 的主机和端口部分会从 localhost:8000 变为你真实的域名。

将这个特殊的授权回调路径用一个易于理解的名称保存起来:

auth_callback_path = "/auth_redirect"
注意

建议将客户端 ID 和密钥存储在环境变量中,而不是硬编码在你的代码里。

当用户访问你应用的普通页面时,如果他们尚未登录,那么你需要将他们重定向到你应用的登录页面,该页面位于 /login 路径。我们通过使用这段“前置件”(beforeware)来实现这一点,它定义了在处理所有路由(除了我们指定要跳过的路由)之前运行的逻辑。

def before(req, session):
    auth = req.scope['auth'] = session.get('user_id', None)
    if not auth: return RedirectResponse('/login', status_code=303)
    counts.xtra(name=auth)
bware = Beforeware(before, skip=['/login', auth_callback_path])

我们将前置件配置为跳过 /login,因为那是用户登录的地方;我们还跳过了特殊的授权回调路径,因为 OAuth 本身使用它来从 GitHub 接收信息。

只有在你的登录页面,我们才开始 OAuth 流程。要启动 OAuth 流程,你需要给用户一个指向 GitHub 登录页面的链接,该页面是为你的应用准备的。你需要 client 对象来生成这个链接,而客户端对象又需要完整的授权回调 URL,我们需要从授权回调路径构建它,所以生成这个 GitHub 登录链接是一个多步骤的过程。

以下是你自己的 /login 路由处理程序的实现。它生成 GitHub 登录链接并呈现给用户:

@app.get('/login')
def login(request)
    redir = redir_url(request,auth_callback_path)
    login_link = client.login_link(redir)
    return P(A('Login with GitHub', href=login_link))    

一旦用户点击该链接,GitHub 会要求他们授权你的应用访问他们的 GitHub 账户。如果他们同意,GitHub 会将他们重定向回你应用的授权回调 URL,并携带一个授权码,你的应用可以用它来生成访问令牌。为了接收这个码,你需要在 FastHTML 中设置一个路由,监听授权回调路径上的请求。例如:

@app.get(auth_callback_path)
def auth_redirect(code:str):
    return P(f"code: {code}")

这个授权码是临时的,你的应用用它来直接向提供商请求用户信息,如访问令牌。

总结一下,到目前为止的交互可以看作是:

  • 用户对我们说:“应用,我想用你登录。”
  • 我们对用户说:“好的,但首先,这是一个用 GitHub 登录的特殊链接。”
  • 用户对 GitHub 说:“GitHub,我想用你登录来使用这个应用。”
  • GitHub 对用户说:“好的,正在将你重定向回应用的 URL(并附带一个授权码)。”
  • 用户对我们说:“嗨,应用,我回来了。这是你需要向 GitHub 索取我信息的授权码。”(通过 /auth_redirect?code=... 传递)

我们需要实现的最后步骤如下:

  • 我们对 GitHub 说:“一个用户刚给了我这个授权码。我可以获取用户信息(例如,访问令牌)吗?”
  • GitHub 对我们说:“既然你有授权码,这是用户信息。”

在授权回调中立即从授权码派生出用户信息至关重要,因为授权码只能使用一次。所以我们用这一次来获取像访问令牌这样的信息,这些信息在更长的时间内有效。

要从授权码获取用户信息,你需要使用 info = client.retr_info(code,redirect_uri)。从用户信息中,你可以提取 user_id,这是一个用户的唯一标识符。

@app.get(auth_callback_path)
def auth_redirect(code:str, request):
    redir = redir_url(request, auth_callback_path)
    user_info = client.retr_info(code, redir)
    user_id = info[client.id_key]
    return P(f"User id: {user_id}")

但我们想要 user ID 不是为了打印它,而是为了记住这个用户。

所以我们把它存储在 session 对象中,以记住谁已经登录了。

@app.get(auth_callback_path)
def auth_redirect(code:str, request, session):
    redir = redir_url(request, auth_callback_path)
    user_info = client.retr_info(code, redir)
    user_id = user_info[client.id_key] # get their ID
    session['user_id'] = user_id # save ID in the session
    return RedirectResponse('/', status_code=303)

会话对象派生自用户浏览器可见的值,但它经过加密签名,因此用户无法自己读取。这使得即使存储我们不想暴露给用户的信息也是安全的。

对于更大量的数据,我们希望将这些信息保存在数据库中,并使用会话来保存从该数据库查找信息的键。

这里有一个将所有这些部分组合在一起的最小化应用。它使用用户信息来获取 user_id。它将 user_id 存储在会话对象中。然后,它使用 user_id 作为数据库的键,该数据库跟踪每个用户点击递增按钮的频率。

import os
from fasthtml.common import *
from fasthtml.oauth import GitHubAppClient, redir_url

db = database('data/counts.db')
counts = db.t.counts
if counts not in db.t: counts.create(dict(name=str, count=int), pk='name')
Count = counts.dataclass()

# Auth client setup for GitHub
client = GitHubAppClient(os.getenv("AUTH_CLIENT_ID"), 
                         os.getenv("AUTH_CLIENT_SECRET"))
auth_callback_path = "/auth_redirect"

def before(req, session):
    # if not logged in, we send them to our login page
    # logged in means:
    # - 'user_id' in the session object, 
    # - 'auth' in the request object
    auth = req.scope['auth'] = session.get('user_id', None)
    if not auth: return RedirectResponse('/login', status_code=303)
    counts.xtra(name=auth)
bware = Beforeware(before, skip=['/login', auth_callback_path])

app = FastHTML(before=bware)

# User asks us to Login
@app.get('/login')
def login(request):
    redir = redir_url(request,auth_callback_path)
    login_link = client.login_link(redir)
    # we tell user to login at github
    return P(A('Login with GitHub', href=login_link))    

# User comes back to us with an auth code from Github
@app.get(auth_callback_path)
def auth_redirect(code:str, request, session):
    redir = redir_url(request, auth_callback_path)
    user_info = client.retr_info(code, redir)
    user_id = user_info[client.id_key] # get their ID
    session['user_id'] = user_id # save ID in the session
    # create a db entry for the user
    if user_id not in counts: counts.insert(name=user_id, count=0)
    return RedirectResponse('/', status_code=303)

@app.get('/')
def home(auth):
    return Div(
        P("Count demo"),
        P(f"Count: ", Span(counts[auth].count, id='count')),
        Button('Increment', hx_get='/increment', hx_target='#count'),
        P(A('Logout', href='/logout'))
    )

@app.get('/increment')
def increment(auth):
    c = counts[auth]
    c.count += 1
    return counts.upsert(c).count

@app.get('/logout')
def logout(session):
    session.pop('user_id', None)
    return RedirectResponse('/login', status_code=303)

serve()

需要注意的一些事项:

  • before 函数用于检查用户是否已认证。如果未认证,他们将被重定向到登录页面。
  • 要将用户登出,我们从会话中移除用户 ID。
  • 调用 counts.xtra(name=auth) 确保在响应请求时只能访问与当前用户对应的行。这通常比在每个路由中都记得过滤数据要好,并且降低了意外泄露数据的风险。
  • auth_redirect 路由中,我们将用户 ID 存储在会话中,并在 user_counts 表中创建一个新行(如果它尚不存在)。

你可以在 fasthtml-example 的 oauth 目录中找到此代码的更详细注释版本,以及一个更精简的示例。将来可能会添加更多示例。

撤销令牌 (Google)

当上述示例中的用户登出时,我们从会话中移除了他们的用户 ID。然而,用户仍然登录在 GitHub 上。如果他们再次点击“使用 GitHub 登录”,他们将被重定向回我们的网站,而无需再次登录。这是因为 GitHub 记住了他们已经授予我们的应用访问他们账户的权限。大多数情况下这很方便,但出于测试或安全目的,你可能需要一种方法来撤销此权限。

作为用户,你通常可以从提供商的网站上撤销对应用的访问权限(例如,https://github.com/settings/applications)。但作为开发者,你也可以通过编程方式撤销访问权限——至少对于某些提供商是这样。这需要跟踪访问令牌(在你调用 retr_info 后存储在 client.token["access_token"] 中),并向提供商的撤销 URL 发送一个请求。

auth_revoke_url = "https://#/o/oauth2/revoke"
def revoke_token(token):
    response = requests.post(auth_revoke_url, params={"token": token})
    return response.status_code == 200 # True if successful

并非所有提供商都支持令牌撤销,目前 FastHTML 客户端中也未内置此功能。

使用 State (Hugging Face)

想象一下,一个用户(未登录)来到你的 AI 图像编辑网站,开始测试功能,然后意识到他们需要登录才能点击正在进行的编辑上的“运行(专业版)”。他们点击“使用 Hugging Face 登录”,登录后被重定向回你的网站。但现在他们正在进行的编辑丢失了,只能看到主页!这是一个你可能希望跟踪一些额外状态的案例。能够通过 OAuth 流程传递一些唯一状态的另一个重要用例是防止一种叫做跨站请求伪造(CSRF)攻击。要向 OAuth 流程添加一个状态字符串,请在创建登录链接时包含一个 state 参数。

# in login page:
link = A('Login with GitHub', href=client.login_link(state='current_prompt: add a unicorn'))

# in auth_redirect:
@app.get('/auth_redirect')
def auth_redirect(code:str, session, state:str=None):
    print(f"state: {state}") # Use as needed
    ...

状态字符串会通过 OAuth 流程传递并返回到你的网站。

仍在开发中

此页面(以及 FastHTML 中的 OAuth 支持)仍在开发中。欢迎提问、提交拉取请求(PR)和反馈!