为了账号安全,请及时绑定邮箱和手机立即绑定

NiceGUI与FastAPI中的用户认证与权限控制怎么做

标签:
Python 安全 API

为自己的 web 应用创建认证管道真是件头疼的事。谈到认证,我绝对不会建议你从头开始做,自己写管道。尽可能利用现有的库或平台。说起来容易做起来难,尤其是不用 JavaScript 的时候(好像有很多库支持 JavaScript 的认证和授权)。

我的整个技术栈要么是 Go,要么是 Python,——是的,网页前端也是用 Python 编写,例如使用 NiceGUIFastAPI。如果你也有类似的情况,你基本上有两种选择,

  • 使用像 Supabase、Auth0 和 Taipy 这样的预封装服务,只需少量编码,但却需要花一大笔钱。
  • 自己动手搭建管道的一部分,使用现有的所有库和服务。

由于对非安全领域的人员来说,authN 和 authZ 是比较晦涩的概念,我将这篇文章的结构做一些不同。我将直接在第一部分给出主要内容,而在文章的后半部分,我会详细解释这些概念的用途和相关背景。

烤肉(一种墨西哥烤牛肉)

这个概念很简单:尽量少做工作,获取最大收益。使用OAuth提供商进行身份验证,并且不要提供密码登录。比如Google、X、Facebook、Slack、微软的Live、GitHub、LinkedIn等都提供免费的OAuth接口。你可以在同一个应用里提供多个选择。工作流程、代码以及返回的数据格式都是一样的(虽然每个提供商的OAuth URL不同,但你知道我的意思)。

配料清单:

你需要的使用 Blah 登录

  • 为了使用他们的服务或访问他们的API,你得在OAuth提供商那里注册你的应用。这很简单。你需要给你的应用取个名字(这只是一个字母数字字符串,没什么特别的,不必是全局唯一的或可验证的),一些可选的描述文本和一个必须的重定向URI。这个URI需要是可以公开访问的。然后提供商将给你一个CLIENT_ID和一个CLIENT_SECRET,你就得用它们。如果你还不熟悉OAuth:重定向URI就是OAuth提供商在你代表用户触发OAuth流程时用来发送认证令牌的那个路由。
  • 一个存放注册用户元数据的用户数据库(废话)。
  • 一个你注册好了的前端/应用(没💩!)
  • 一个cookie:通过NiceGUI的app.storage.browser设置和获取。
  • 加密解密密钥。
厨房

认证 AuthN、授权 AuthZ 以及用户注册

当用户点击使用Blah登录时,他们会被引导至提供商的authorize链接(授权链接)。例如,如下:

  • 对于 Slack,其链接为 [https://slack.com/openid/connect/authorize](https://slack.com/openid/connect/authorize)
  • 对于 LinkedIn,其链接为 [https://www.linkedin.com/oauth/v2/authorization](https://www.linkedin.com/oauth/v2/authorization)

与其在自己的代码中构建请求代码,我建议使用 Python 的 authlib 库的 starlette 集成功能——因为 NiceGUI 使用 FastAPI 作为 API 路由器,而 FastAPI 内部使用 Starlette 作为中间件。这里提供的重定向 URI 必须与你在应用注册时提供的重定向 URI 完全一致。正如我之前说过的,重定向 URI 必须是可路由的。

    从 authlib.integrations.starlette_client 导入 OAuth  
    从 nicegui 导入 app  

    oauth = OAuth()  

    @app.on_startup  
    def 初始化服务():  
        global oauth  

        oauth.register(  
            "google",  
            client_id=GOOGLE_CLIENT_ID,  
            client_secret=GOOGLE_CLIENT_SECRET,          
            server_metadata_url=GOOGLE_SERVER_METADATA_URL,  
            authorize_url=GOOGLE_AUTHORIZE_URL,  
            access_token_url=GOOGLE_ACCESS_TOKEN_URL,  
            api_base_url=GOOGLE_API_BASE_URL,  
            userinfo_endpoint=GOOGLE_USERINFO_ENDPOINT,  
            client_kwargs=GOOGLE_OAUTH_SCOPE,  
            user_agent=APP_NAME  
        )  # 设置谷歌OAuth
        oauth.register(  
            "slack",  
            client_id=SLACK_CLIENT_ID,  
            client_secret=SLACK_CLIENT_SECRET,  
            server_metadata_url=SLACK_SERVER_METADATA_URL,  
            authorize_url=SLACK_AUTHORIZE_URL,  
            access_token_url=SLACK_ACCESS_TOKEN_URL,  
            api_base_url=SLACK_API_BASE_URL,  
            client_kwargs=SLACK_OAUTH_SCOPE,  
            user_agent=APP_NAME  
        )  # 设置Slack OAuth

    @app.get("/oauth/google/login")  
    async def 谷歌OAuth登录(request: Request):  
        return await oauth.google.authorize_redirect(request, os.getenv("BASE_URL") + "/oauth/google/redirect")  

    @app.get("/oauth/google/redirect")  
    async def 谷歌OAuth重定向(request: Request):  
      ...

提供商有自己的身份验证界面。用户完成点击操作后,提供商将回调重定向URI,传递认证令牌和包括姓名、邮箱等信息的用户元数据,或者在认证失败时返回失败信息。

如果认证成功了,使用用户邮箱检查该用户是否在用户数据库中注册。我想不用多说,在遇到认证失败时该怎么做吧。如果用户已经注册,则从用户数据库中加载必要的用户信息。

    @app.get("/oauth/google/redirect")  
    async def google_oauth_redirect(request: Request):  
        try:  
            token = await oauth.google.authorize_access_token(request)  
            return process_oauth_result(token)  
        except Exception as err:  
            return RedirectResponse("/")  

    REGISTRATION_INFO_KEY = "registration_info"  

    def process_oauth_result(result: dict):  
        # userinfo, email, iss 是 oauth 响应中的标准字段,如 userinfo、email 和 iss  
        existing_user = user_db.get_user(result['userinfo']['email'], result['userinfo']['iss'])  
        if existing_user:  
            login_user(existing_user)          
            return RedirectResponse("/user/home")  
        else:  
            login_user(result['userinfo'])  
            # 将 userinfo(不包括 token)暂时存储在这里,在注册之前作为临时存储  
            app.storage.browser[REGISTRATION_INFO_KEY] = result['userinfo']  
            return RedirectResponse("/user/register")

创建一个包含过期日期和用户电子邮件的加密JWT令牌。最好让过期日期比从用户身份验证令牌中获得的过期日期更短。如果是X/Twitter,这种情况是无限的,那么给你的JWT设置一个星期或一个月(或更短的时间)。

将 JWT 设置为浏览器 cookie。在 NiceGUI 中,你可以使用 app.storage.browser 字典来实现。现在可以将用户重定向到他们的主页或任何他们需要访问的受保护页面。

    import jwt   

    JWT_TOKEN_KEY = 'espressotoken'  # JWT令牌的键名
    JWT_TOKEN_LIFETIME = timedelta(days=7)  # JWT令牌的有效期设置为7天

    def create_jwt_token(email: str): 
        """
        创建JWT令牌,将用户电子邮件、签发时间和过期时间作为数据。
        """
        data = {  
            "email": email,  
            "iat": datetime.now(),  # 签发时间
            "exp": datetime.now() + JWT_TOKEN_LIFETIME  # 过期时间
        }  
        return jwt.encode(data, APP_STORAGE_SECRET, algorithm="HS256")  # 使用HS256算法编码数据

    def login_user(user: dict|User):  
        """
        登录用户,从用户对象或字典中获取电子邮件,然后创建JWT令牌并存储在浏览器中。
        """
        email = user.email if isinstance(user, User) else user['email']  
        # 将生成的JWT令牌存储在浏览器中
        app.storage.browser[JWT_TOKEN_KEY] = create_jwt_token(email)

如果用户未注册,则将该认证用户引导到用户注册页面,在该页面上他们将接受(或不接受)您随意编写的条款作为使用条款和隐私政策内容(或完全没有)。为此,请直接链接到您仓库中的 Markdown 文件即可。

    def 提取注册信息():  
        val = app.storage.browser.get(REGISTRATION_INFO_KEY)  
        if not val:  
            raise HTTPException(status_code=401, detail="未授权")  
        del app.storage.browser[REGISTRATION_INFO_KEY]  
        return val  

    @ui.page("/user/register", title="Espresso用户注册")  
    async def 注册用户(userinfo: dict = Depends(提取注册信息)):  
        await 显示注册页面(userinfo)

当用户同意时,使用他们的元数据注册用户。遵循一个原则——尽量少地收集和存储用户信息。知道得越少,需要保护的也就越少,越不容易成为攻击用户的渠道。例如,我只收集电子邮件(因为这是ID),姓名(因为我喜欢称呼用户的名字……一次又一次地),以及头像URL(这其实没什么必要,但挺酷的,显示他们的照片)。

    class UserDB:  
      ...  
      def create_user(self, userinfo: dict):  # 创建用户
            user = User(  
                id=userinfo["email"],   # 用户ID
                email=userinfo["email"],   # 邮箱
                name=userinfo["name"],   # 名字
                image_url=userinfo.get("picture"),   # 图片链接
                created=datetime.now(),  # 记录创建时间
                updated=datetime.now(),  # 记录更新时间
                linked_accounts=[userinfo["iss"]],  # 关联账户
            )  
            self.users.insert_one(user.model_dump(exclude_none=True, by_alias=True))  # 将用户信息插入数据库中,转换为字典,排除空值并使用别名
            return user

一旦用户注册完成,就可以进入到JWT令牌生成的部分,剩下的都一样。

现在的问题是这样的,如果用户在你的应用中导航到其他页面怎么办?你可以使用 app.storage.browser 字典来获取你设置为自定义浏览器 cookie 的 JWT。使用你的 APP_STORAGE_SECRET(或你用来加密它的密钥)进行解密。邮箱可以告诉你这个请求来自哪个用户。因此,你知道要显示哪些用户特定的内容。如果没有找到 JWT 的 cookie,你可以认为流量来自于一个未认证的用户。

    import jwt   

    def extract_user() -> User:  
        token = app.storage.browser.get(JWT_TOKEN_KEY)  
        if not token:  
            return None  
        data = decode_jwt_token(token)  
        if not data:  
            del app.storage.browser[JWT_TOKEN_KEY]  
            return None  
        user = user_db.get_user(data["email"])  
        if not user:  
            del app.storage.browser[JWT_TOKEN_KEY]  
            return None  
        return user   

    def decode_jwt_token(token: str):  
        try:  
            # 验证参数设置为检查令牌是否过期  
            data = jwt.decode(token, APP_STORAGE_SECRET, algorithms=["HS256"], verify=True)  
            return data if data and "email" in data else None  
        except Exception as err:  
            log("jwt_token_decode_error", user_id=app.storage.browser.get("id"), error=str(err))  
            return None   

    @ui.page("/baristas/{barista_id}", title="Espresso")  
    async def barista(  
        user: User = Depends(extract_user),  
        barista_id: str  
    ):   
        await render_barista_page(user, barista_id)

如果当前日期已过期,你可以做两件事情中的任意一件。

  • 让用户登出并将其重定向到未授权的 URL。如果用户还想看这些内容,可以重新登录,这就是代码干的活儿。
  • 使用从 OAuth 流程中获得的刷新令牌,重新向 OAuth 提供商验证身份,为用户在后台生成一个新的 JWT,更新 cookie 并让用户看到受保护的页面。
别烧你的塔可

需要注意的几点——

  • 如果您使用 refresh-token 为用户重新认证,您需要一个安全的地方来存储 token,请不要将 APP_STORAGE_SECRET 放到代码库中检查(如果您使用 app.storage.user 就会出现这种情况)。请将这些内容存储在数据库表中并进行加密。
  • APP_STORAGE_SECRET 或您用于加密 JWT 的密钥是攻击向量,您需要将该密钥准备好以解密传入的 JWT。您可以在部署应用时将其设置为环境变量。或者,这可以是一个存储在数据库中的轮换值。如果更换了加密密钥,那么通过您之前的密钥加密的 JWT 将无法正确解密,这些流量就会被视为未认证的流量。我的建议:将此密钥的轮换周期设置得比 JWT 的过期周期长。同时维护两个密钥并尝试用这两个密钥进行解密。
  • 请不要将 APP_STORAGE_SECRET 放到代码库中检查。这被视为通过代码泄露的密钥……非常糟糕的事情。这么做太可惜了。

所以这就是用NiceGUI和FastAPI来制作 carne asada 塔可的大致过程。我选择这种设计有很多原因。如果你自虐心足够强,可以往下看。

基本知识
  • 身份验证:这家伙真是他说的那个家伙吗?获取一些证明比如用户名+密码或者Google OAuth。这一步是为了验证他的真实身份。这并不能实际说明他是否有权限给你发私信。
  • 授权:你知道这家伙吗?他注册了吗?他是否有权限给你发私信?
  • 注册:新家伙想要注册你的每日成人图片推送。需要有一个注册流程,让这家伙同意接收你所说的那些成人图片(也就是你的网站条款、隐私政策之类的)。这不需要像微软和甲骨文之间的正式合同一样正式。为了防止你的 🍑 避免遭受法律 🐮 💩 的威胁,你应该有一个用户协议页面。
  • 认证刷新:你应该有认证刷新机制。这是最基本的保障措施。如果你在想这是什么——类似于你那个需要每天听到你夸她穿着牛仔裤好看以及你每天爱她的女朋友。基本来说,当你用Google(或者其他OAuth提供者)登录时,你会得到一个认证令牌和一个过期时间。其思路是Google在一段时间内信任你,直到那时,你可以在后续的调用中继续使用那个令牌,而不需要重新验证或重新认证。过期后,你可能需要重新触发认证工作流程,或者使用刷新令牌刷新认证令牌(如果认证提供者给了你刷新令牌)。即使你的应用程序没有使用Google(或其他提供者)的数据API,你也应该以某种形式“遵守”那个时间限制原则。注意:并不是所有的OAuth提供者都给他们的认证令牌设置过期时间,例如,Twitter/X 给你一个永久的令牌 #YOLO。
圣句

记住这一点,就把这些话当作是上帝的旨意。

  • 千万不要,我再重复一遍,千万不要自己创建基于用户名和密码的场景。密码会让你陷入加密、哈希、加盐、撤销、恢复、更新等各种麻烦,真是自找苦吃。去他妈的!

  • 使用像微软、谷歌、领英这样的知名OAuth提供商。他们已经做了大量的工作来运行一个安全和可靠的认证平台——比你那破烂的创业公司做得多得多。

  • 不要在文件系统中存储认证令牌或刷新令牌。如果你感觉自己特别有灵感要存到某个地方,至少用数据库并加密存储。总之,还是别这么干。

  • 当涉及到用户元数据时,尽量少存信息。身份提供者可以给你一堆身份服务的元数据。你真正需要的只有:一个邮箱用于唯一标识。一个名字,如果你想和用户打招呼说“你的名字是什么”。如果你不知道这句话的出处,你可能需要多看些相声,而不是浪费时间在这些认证上。如果你想要更进一步,你可以存一个头像的url(我这么做是因为我觉得看着别人的脸很有趣)。
勒舞解释或说明:这可能是某个活动或舞蹈风格的名称。

考虑到这些,你可以这样做——

  • 进行一次OAuth认证。
  • 检查是否为已注册用户。
  • 如未注册,则进行注册。
  • 分配名为 my-fancy-app-user-email 的 cookie,其值为电子邮件地址。

然后我会检查是否存在名为 my-fancy-app-user-email 的 cookie,然后根据它的值来提供页面。就这样,搞定啦!

这真是太可怕了! 因为现在任何人都可以这样做 request.get(cookie={'my-fancy-app-user-email': 'blah@blah.blah'}),你就麻烦了!

好的,我会把令牌直接设置成 cookie,有一个令牌到用户的映射。

但是……毕竟有安全问题吧……

  • 用户停用或删除了他的 Slack 账户。现在身份验证失效了,但你仍然允许他访问,或者——
  • 浏览器漏洞导致 cookie 被盗,因此攻击者现在可以访问用户的 Slack 认证令牌。所以,他的整个 Slack 账户访问权限都被你的应用给泄露了。

好的,我会先加密再设置为 cookie。

所以,你把这位朋友的Slack账号从浏览器上的漏洞救回来了,可惜。

  • 账号停用、删除或令牌撤销的问题仍然存在。
  • 现在你要写一堆这样的代码来做加密、解密、哈希和加盐吗?

啊哈!我要生成自己的 token,加密它,然后将其设置为浏览器 cookie,

所以……你在说——你要生成一个唯一标识符(比如GUID),然后把这些令牌加密保存在一个数据库里,并维护它们和用户之间的映射关系?你还要管理每个令牌的状态,比如激活、撤销或过期吗?

老兄,你干脆把整个系统都搞定了。这没问题,但那可是为了一个你刚刚起步推广的应用做了一大堆工作。以后的调试和可靠性问题那么多,这怎么行!

这就是为什么
  • 我不仅仅使用用户的电子邮件地址,因为这样会让攻击者有机会在请求中随意填写任何电子邮件地址。
  • 我使用 JWT,以防止账号被劫持。
  • 我使用的 JWT 是自行管理的,并且自带过期时间字段,因此我不需要维护一个大量的 token 数据库。
  • 我不会设置永不设置过期时间的 JWT。
  • 我不会存储 OAuth 提供商的认证 token 或刷新 token。
  • 当 JWT 过期时,我会触发重新认证。这样,如果用户在 OAuth 提供商处的账号被禁用或失效,下次认证就会失败。
  • 我使用通过 app.storage.browser 设置的 cookie,因为 NiceGUI 会对它进行加密。

如果你对NiceGUI比较熟悉,并且感到疑惑为什么我没有使用其他的存储方式,例如usertab

    +------------------------+------------------------------------------------------+
    | 类型                   | `tab`   | `client` | `user`  | `general` | `browser` |
    +------------------------+---------|----------|---------|-----------|-----------|
    | 位置                   | 服务器  | 服务器   | 服务器  | 服务器    | 浏览器    |
    | 跨标签                 | 否      | 否       | 是      | 是        | 是        |
    | 跨设备                 | 否      | 否       | 否      | 是        | 否        |
    | 服务器重启时            | 否      | 否       | 否      | 是        | 否        |
    +------------------------+------------------------------------------------------+
  • general 特定于应用程序,而不是用户。

  • client 特定于当前的浏览会话。

  • tab 太专门针对具体的标签页,而不是跨越浏览器的未来会话。

  • user 文件系统中的明文。

这是来自NiceGUI网站上的 -

(Note: The hyphen at the end is retained as per the original source text, but typically, it would be removed for a more natural Chinese sentence flow. If strictly adhering to expert suggestions, the hyphen should be removed.)

Given the instruction to retain the original formatting, including any trailing symbols, the hyphen is kept. However, for a more polished translation, it would be advisable to remove it.

app.storage.user: 服务器端存储,每个数据存储都与浏览器会话 cookie 中持有的唯一标识符关联。每位用户都有一个专属的存储空间,可以在所有浏览器标签页中访问。通过 app.storage.browser['id'] 来识别用户。

**app.storage.browser** :与之前提到的 app.storage.user 不同,这个数据存储直接存储为浏览器会话 cookie,在所有浏览器标签页之间共享。不过,由于它减少了数据负载、增强了安全性、并且提供了更大的存储容量,所以我们更推荐使用 app.storage.user。默认情况下,NiceGUI 在 app.storage.browser['id'] 中保存了一个唯一的会话标识符。

关于“app.storage.user 以增强安全性”的部分,这完全是 🐮 💩。它把所有东西都明文存储在文件系统的 json 文件中。这TM的,兄弟!还有,app.storage.browser 在服务器重启后无法保持持久化——这只是一个用户的浏览器 cookie,cookie 由浏览器自动维护,你根本不需要操心这个。

快餐塔科斯

这可能有点难以接受。好消息是,这里有一些现成的解决方案可以与你的应用程序即插即用,你无需担心那些细节。

有了这些——

  • 你不需要太多地去想认证(authN)和授权(authZ)相关的代码细节。
  • 你可以像调用其他数据API那样简单地调用这些API。

他们也都提供

  • 用户注册、注销和元数据存储。
  • 密码加密、哈希、加盐及恢复功能。
  • 集成不同的OAuth提供商,如Live微软、Google、Facebook、X/Twitter、LinkedIn、Github。
  • 根据您的配置,UI/登录注册页面带有多个登录/注册按钮。
  • 后台的重新身份验证/刷新令牌——因此您无需担心这些问题。

Taipy在代码中甚至可以轻松实现与在Taipy企业平台上托管并使用Taipy编写的应用程序的集成。

但所有这些都得付出代价……好吧——

  • Azure Web App 的认证功能对于部署在 Azure Web App 基本层级及以上层级的应用是免费的(达到一定使用量上限,费用已经包含在层级内)。
  • Keycloak 是开源的,实际上免费使用,但现在你需要支付托管服务费并管理身份验证基础设施的 SRE。

你可能需要随身带着一些100美元的钞票。此外,调试集成、交互流程和重定向可能会让你觉得跳桥都没那么难过了。

声明:本文和作者均未接受这些供应商的产品的赞助,也不希望得到。提到他们是因为这是我试过的产品。当然还有许多其他选择,您可以根据自己的喜好去试试。

说完了这些,我内心的安全工程师还是不太满意,因为这还不是最完美的实现。但是,我内心那个不羁的创业创始人则激动不已,因为简直太棒了!总之,干杯!✌️

附言

"PS PS"

我知道这些部分标题的措辞真的很糟糕,充满了蹩脚的法语和西班牙语。但这确实是我整天都在脑子里转的东西。所以……慢慢享用吧

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消