supabase/auth源码分析
June 18, 2024
supabase/auth采用golang实现,本文分析supabase/auth后台核心路径源码以及存储结构,掌握其设计原理以及可能遇到的问题。关于supabase/auth的使用方法之前在文章 使用supabase/auth实现用户认证管理 已经介绍过了,所以其基础知识不再重复。
准备工作
在开始分析前,本地搭建了一份supabase/auth的服务,初始化DB之后就可以直接运行了。
初始工作如下:
- 环境变量准备
- create Postgres database & user
- grant public schema to user
error message: permission denied for schema public (SQLSTATE 42501)
- create schema in database
error message: schema \"auth\" does not exist (SQLSTATE 3F000)"
整体结构
项目基于命令行工具cobra开发,支持各种子命令,包括migrate、serve等。本文仅关注服务部分。即使用go原生的http lib构建Restful API接口,为第三方提供注册登录等服务。支持的接口详见这里。
技术栈:
管理类的任务都通过接口提供给到外部触发,所以自身结构比较简单,没有异步任务之类的goroutine。服务启动后再默认端口9999上监听&路由响应。
接口
Supabase/auth提供了不少接口,可以分位 auth(认证&登录)、user(用户管理)、oauth、admin等分类,这里仅分析邮箱认证及登录流程涉及到的几个接口。包括其基础流程和核心表结构。
auth
auth分类的接口包括注册、登录登出等相关的接口。
POST /signup
目的:Signs a user up.
邮件、手机号注册。注册按是否需要confirm可以分为两类:
- 无需confirm:即以用户输入手机号或者email为准,注册完成立即派发jwt。
- 另一种是需要用户确认,所以signup接口只是发送确认邮件或者sms,等待用户确认时调用verify接口才可完成整个流程
参数:
流程分析:
- 参数校验:包括密码长度、手机channel、pkce参数合法性(如果有)
- provider校验
- email:email格式有效性校验;重复性校验(email & audience相同则认为重复用户)
- phone:phone格式有效性检查;重复性校验(phone & audience相同则认为重复用户)
- provider校验
- 创建、校验已有用户 users
- 如果user表已有记录,且用户状态已经确认(XXXConfirmedAt非空),返回拒绝
- 如果user表无记录,新增user表,设置role为DefaultGroupName(user.role ?)。新增完毕后reload user(可能可能存在trigger更新记录)
- 创建认证类型 identity
- identity表有重复记录时跳过,条件:provider+provider_id(即user.id),即之前已经存在的用户不会重复写入identity。否则写入新记录。如果之前用户已存在但是本次为新的provider,也会写入一条记录,表明用户有新增新的认证类型。
- 关联user表和identity表
- 更新user表用户元数据字段(user_metadata),将identity相关的meta(邮箱认证时包括mail、 is_verifiyed等字段)加入用户元数据
- 去掉identity表中多余的identity(?)
- 更新ap元数据(app_metadata)中的provider字段。
- 用户确认(confirm)逻辑(已确认时跳过)
- 配置为自动确认,则:
- 记录审计日志事件表 audit_log_entries ,action:user_signedup
- 确认逻辑
- 更新 user 中email_confirmed_at,表明用户已确认。(confirmation_token为空)
- 清除 confirmation_token 表中用户记录,表明用户不需要再次确认。防止之前已经发送过确认邮件但是用户还没有确认的场景。
- 非自动确认
- 记录审计日志事件 audit_log_entries ,action:user_confirmation_requested
- 如果是PKCE类型:记录 flow_state表。—— 问题:为何DB中的authcode的字段是随机uuid?
- 发送确认邮件到SMTP。
- 频率校验(config.SMTP.MaxFrequency)
- 生成一串用于确认的TokenHash,算法:sha256(mail/phone + random OTP),期中random OTP为最大长度受限的随机串。TokenHash会被当做query参数的一部分写入确认邮件的链接中,用于verify时确认用户身份。
- sendMail发送邮件
- 取确认URL,顺序:header.redirect_to、form.redirect_to、header.refer、config.SiteURL。
- 使用配置hook 或者 gomail lib 发送邮件。
- TokenHash会写入用户表 user 的ConfirmationToken字段,用于verify时的过期校验。会增加flow_type前缀,用于识别是否PKCE流。同时更新confirmation_sent_at字段(用于判断有效期?)。
- 写入 one_time_token 表一条记录,供verify时校验使用。
- 配置为自动确认,则:
- 登录,票据发放(如果用户为以确认状态,包括自动确认)
- 记录审计日志事件 audit_log_entries ,action:login
- 颁发refresh-token,生成session(verify接口中也有相同逻辑)
- 生成refresh-token,记录到 refresh-token 表
- 如果无session,生成session记录,记录到 session 表。更新用户表 UpdateLastSignInAt 字段。
- 记录 mfa_amr_claims 表,表明本次session所采用的认证方法(定义详见RFC8176)。
- 生成access-token,使用 golang-jwt 库生成。
- 将access-token、refresh-token写入cookie
access-token中打包了一系列claims数据,包括:
如果用户为已确认状态,则返回票据信息。否则返回用户信息。
POST /verify
作用:Authenticate by verifying the posession of a one-time token.
参数:
流程分析:
- 参数校验:如果没有TokenHash参数,则使用Token+email/phone生成TokenHash,算法跟signup中一致。(可见Token的定位跟之前的random TOP一致,可能是早期的版本?)
- 校验TokenHash
- 查找用户:根据Type不同会有差异,但是大致流程类似。先查找 one_time_token 表对应记录,然后根据one_time_token 表中的userid查找对应的 user 表记录。代码实现上有点啰嗦。
- 校验otp是否过期(邮件注册类opt会在user.ConfirmationSentAt中记录发送时间)—— 很割裂,为啥不直接记录到otp表
- 根据认证类型,执行不同的认证逻辑
- 注册类(signup、invite)
- invite类,生成临时密码。页面需要提示用户改密。
- 记录审计日志,action:user_signedup
- 用户确认逻辑,与signup中的自动确认一致,包括:更新 user 表中的email_confirmed_at、confirm_token,清除confirmation_token 中用户记录。
- 恢复类【一次性密码】(recovery、magiclink)
- 清除用户表中recovery_token记录。
- 如果用户未确认,写审计日志&执行用户确认逻辑,流程与上面一致。
- 否则记录审计日志,action : login。
- 邮箱变更(email_change)、信息类(sms、phone_change):暂未关注。
- 注册类(signup、invite)
- 更新用户表中app_meta_data中的provider,表示已经经过该provider的认证。
- provider字段值从哪里取? identities 表中有之前的记录,取第一条(最新)?
- 颁发refresh-token,生成session。此步骤跟 signup 中的自动确认后的逻辑一致。
- 将access-token、refresh-token写入cookie
返回:
ps:有个问题,这里认证后 phone_verified 状态还是 false。——已反馈,fix中。
可见整体流程除了增加了部分TokenHash之类的校验外,其他与signup中的自动确认流程大致相同。verify结束之后也会颁发refresh-token等,表明用户已登录。页面不需要再次signin。
另外verify还有一个HTTP Get接口,流程与post类似,不再重复。
POST /token
作用:Issues access and refresh tokens based on grant type.
此接口根据参数不同,承担了多种不同的职责,包括登录、refresh-token刷新等。
本次仅关注mail类型的登录动作(password) 和 refresh-token动作(refreshtoken)刷新,即js-SDK中的 signInWithPassword 、 RefreshTokenTick 对应的后台实现。
passowrd登录
登录代表一个新的session的建立,需要记录session,生成新的refresh-token等。
参数:
流程分析:
- 参数校验:
- 根据参数中的email/phone和aud参数查找用户表记录。
- aud参数来源:按优先级从header(X-JWT-AUD)、JWT cookie或者默认值提取。
- 密码校验:支持hook外部校验、密码安全性校验(长度、弱密码)。
- 未确认用户拦截。
- 登录逻辑
- 记录审计日志,action:login
- 颁发refresh-token,生成session。此步骤跟 signup 中的自动确认后的逻辑一致。
- 将access-token、refresh-token写入cookie
- 记录日志上报logrus
refresh-token刷新
refresh-token请求代表需要一次session的心跳。sdk中会设置定期刷新refresh-token的定时器,来保障本地refresh-token不会被第三方(长期)劫持使用。
参数:
流程分析:
- 参数校验
- 确认 refresh_token 存在对应记录(refresh-token关联了user、session)
- 确认 user 存在对应user以及 session 表存在对应session
- session基础校验。包括session.NotAfter是否满足、config.Sessions.Timebox校验(超过最大时长)、config.Sessions.InactivityTimeout校验(session的refreshed_at是否过期)
- 刷新逻辑,同一refresh-token的刷新请求只能串行执行
- 锁refresh-token表记录(使用pg的行级锁)
- 记录审计日志,action:token_refreshed
- 更新 refresh_token 老token记录,设置revoked标识。
- 创建新token,写入refresh_token 表,并设置Parent为老token。更新用户表的 last_sign_in_at。
- 更新session表中的 refreshed_at、user_agent、ip等。
- Refresh Token Reuse Detection:为了实现refresh-token仅可用一次的同时保障refresh-token的可用性,代码中做了一些兼容处理。避免网络异常或者重复渲染导致的token不可用。
- 如果Revoked已经被标识,表示refresh-token之前已经被使用过了。则会校验当前active token的父token是否为要刷新的token。如果是则复用refresh-token。
- 如果当前active-token的父token不为当前要刷新的token,表示存在恶意请求。撤销本次refresh-token的所有child token(开关控制)
POST /logout
作用:Logs out a user.
根据其scope参数,有三种不同类型的logout动作。
- local类型:根据当前session id(请求参数bear JWT中解析)删除session表记录。表明下一次refresh-token会刷新失败。
- others类型:清理用户名下除了当前session id之外的其他记录。
- global:(默认类型):清理session表当前用户所有记录。
可见logout仅仅清理 session 表记录,没有清除 refresh-token、 user等表记录。
user
用户管理相关接口,仅关注比较特殊的查询用户信息接口。该接口在Next.js demo项目中被存放在了middleware中,所以涉及用户身份的访问都会调用到。所以其本质上也承载了一个票据验证的逻辑。
PUT /user
作用:Update certain properties of the current user account.
supabase文档中有一句对 supabase.auth.getUser() 的说明:
Should always be used when checking for user authorization on the server. On the client, you can instead use getSession().session.user for faster results. getSession is insecure on the server.
即为了保障安全,需要调用服务端的 /user 接口来验证用户授权的有效性,而不是依赖本地的 getSession()。那么我们来分析下该接口都做了些什么来保障authorization的安全的呢。
流程分析:
- requireAuthentication:验证authorization安全性
- 取Bearer token,即之前写入的access-token JWT
- 解析token,写入context。供后面接口逻辑处理使用。key:jwt。(解析jwt依赖config.JWT.Secret,所以业务方或者终端是无法做到的)
- 还原AccessTokenClaims,校验user、session。任意不存在返回失败。
- 根据用户id(claims.Subject)校验 user 表中用户是否存在。存在时写入context。key:user
- 根据session id(claims.SessionId)校验 session 表中会话是否存在,存在时写入context,key:session
- 返回用户表数据
可见该接口主要通过jwt解析、DB中user、session数据的匹配保障了 jwt的安全可靠。注意这里并没有验证session的生命周期,所以即使超过最大时长或者inactive时长,session也不会立即失效。
- 用户登出。此时session表数据会被清理掉,那么requireAuthentication由于无法匹配session字段导致失败。符合预期。
- 会话的生命周期与超时:
- 每个会话都有一个最大生命周期(通常称为时间箱,time-box,为0表示无限制),这意味着会话在创建后有一个固定的存活时间。
- 同样,当用户在一段时间内没有与会话进行任何交互时,会话也会因为不活动而超时。
- 会话的终止方式:
- 当会话达到其最大生命周期或不活动超时时,它们并不会立即被终止。这种设计可能是为了避免在用户正在与系统进行交互时,由于网络延迟或其他原因导致的会话意外终止。
- 会话的清理策略:
- 达到上述状态(即达到最大生命周期或不活动超时)的会话会在24小时后逐步被清理。这意味着系统不会立即删除这些会话,而是给它们一个宽限期,以应对可能的延迟或其他异常情况。
- 允许调整配置或回滚更改:
- 这种设计还允许系统管理员或开发者根据需要调整会话的生命周期和不活动超时的值。
- 如果进行了不当的配置更改,导致用户体验受到影响,管理员或开发者可以在不立即中断用户会话的情况下回滚这些更改。这减少了由于配置错误导致的用户摩擦。
ps: 代码中没有看到定时清理类的任务,不知道这里的24小时清理任务是如何执行的?
表结构
identities:用户认证状态
目的:记录用户的身份认证信息。
user_id: 关联 users 表id。
provider_id:当provider为email时等同于user_id。
provider:对应provider
id:唯一id
identity_data:记录
provider+provider_id存在唯一性约束。
注意:同一用户user可以有多个identity,对应多种不同的认证方式。比如某个邮箱,可能通过email、github两种方式认证。此时identities表中存在两条记录,其中provider不同,但是user_id指向同一用户。
users:用户表
- aud(Audience):接收JWT的一方。配置文件中存有默认值GOTRUE_JWT_AUD="authenticated”。同一email可以注册多个不同的aud。
- role:注册默认角色。默认值:GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated”。有何用途?
- app_meta_data:记录provider以及用户对应的providers列表。比如 {"provider":"email","providers":["email","github"]} 表示邮件用户有经过email和github两种认证方式。
- user_meta_data:聚合各种provider的identity_data。比如用户有邮箱类型的identity认证时,那么email、email_verified等会写入此字段。用户有github类型的identity认证时,github的username、avatar等字段也会写入。
- confirmation_token:等待认证的HashToken(即one_time_token中等待用户verify的token)
- iss 是 Issuer 的缩写,值是颁发这个OpenID的URL
- sub 是 Subject 的缩写,指的是这个OpenID对应的用户
- aud 是 Audience(s) 的缩写,指的是这个OpenID所授权的对象。
- exp 是 超时时间
- iat 是 颁发时间
- nonce 是随机字符串 https://openid.net/specs/openid-connect-core-1_0.html#IDToken
sessions表
- aal:aal1 保证级别1,表示使用常规登录方法验证了用户的身份例如电子邮件+密码、magic link、一次性密码、电话授权或社交登录。 aal2 保证级别2,意味着用户的身份已使用至少一个,例如TOTP码。默认值为aal1。仅通过 totp 方式认证才会被记录为 aal2。
- factor_id:用户传入,有啥用?
- not_after:生效时间戳?
- tag?没有找到赋值的地方
refresh_tokens表
记录派发过的所有refresh_token。
- session_id:对应的session id
- parent:refresh时上一次的token(refresh-token-rotation要求refresh-token仅允许使用一次)
- revoked? 撤销
mfa_amr_claims认证方法表
存储多因素认证(mfa)中的认证方法参考值(Authentication Method Reference Values),主要用于记录session认证时采用到的认证方法列表。
“multi factor authentication”即多因素认证,是一种安全验证方法,它要求用户提供两种或更多种不同的身份验证凭据才能访问资源,以增加账户的安全性。而AMR claims 表示身份验证事件期间使用的特定身份验证方法或流程。
注意supabase/auth中的值定义与RFC存在差异。
- session_id:关联session表的id
- authentication_method: 身份认证方法。详见RFC8176 规范
- id:uuid唯一id。
此表存在唯一性约束:session_id + authentication_method,记录存在时更新update_at即可。
audit_log_entries 审计日志
审计日志,记录user_signedup、login等事件。
flow_state
pkce的flow state表
one_time_tokens OTP状态表
- user_id:用户id,用于关联用户表
- token_type: otp事件类型
- token_hash:发送给用户的链接中的query参数
结语
supabase/auth作为用户认证管理平台,其功能比较丰富。对各种账号类型支持比较晚上,包括邮箱/手机和第三方oauth认证。其核心算法大部分采用第三方lib。比如jwt、SHA等,整体流程也比较清晰。
谈一些可以优化的点:
- 代码流程分支较多,封装得不够好。比如对于signup中手机和mail的注册,有很多if/else分支,不太符合良好的设计原则。后面也很难扩展。当然这也算开源项目比较常见的毛病了。
- 对postgres DB依赖较高。以Get user接口为例,在我的项目中调用时间在middleware中,这意味会有大量的验证请求。而这里每次请求至少会查询两次db。虽然保障了安全,但是大概率pg的瓶颈就是系统qps的瓶颈。优化方案一是将数据缓存化,而非db。二是将验证放到使用方,比较jwt secret采用公钥的方式丢给第三方服务或者client去管理。