使用supabase/auth实现用户认证管理
June 12, 2024
Supabase 是一个开源的 Firebase 替代方案,其中包含了用户认证及权限管理模块supabase/auth,个人项目中为了实现用户登录能力,经多方预研后决定基于supabase来实现。原因如下:
- 开源:开源且支持自部署。官方的free plan目前也足够了。
- 高灵活性:支持多种接入方式,无论是通过官方的auth UI快速集成,还是使用SDK和API自定义认证页面。
- 强大功能:不仅支持用户认证和授权,还采用JWT进行认证,基于Supabase的Row Level Security实现细粒度授权。同时,支持主流第三方认证提供商,包括匿名登录。
- 完善文档与活跃社区:文档齐全,社区活跃,提供丰富的SDK,对Next.js项目特别友好。
- 安全性:Supabase的认证模块基于Netlify开源的GoTrue项目,安全设计周全,值得信赖。
背景知识
关于认证OAuth几种模式之类的基础知识这里不再赘述,主要关注接入过程中涉及到的知识点。
用户认证
在Supabase中,用户是经过身份验证和授权机制确认的个体,他们具有不同的角色和权限,可以执行相应的操作并访问授权的资源。Supabase支持多种认证方式,包括:
- 邮件和密码认证
- 无密码认证:比如OTP(one-time apssword、magiclink)
- OAUTH:比如google、git等
- SAML SSO
认证完毕后就可以派发access-token、refresh-token等信息给到使用方进行身份验证。Supabase采用session和JWT结合的方式进行用户身份验证。
Session
用户完成设备认证后,会在Supabase的数据库中生成一条session记录( auth.session table),并作为用户后续登录的凭据。核心字段包括:id(session_id)、user_id、创建/更新时间、ip、UA等。
type Session struct {
ID uuid.UUID `json:"-" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
// NotAfter is overriden by timeboxed sessions.
NotAfter *time.Time `json:"not_after,omitempty" db:"not_after"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
FactorID *uuid.UUID `json:"factor_id" db:"factor_id"`
// 认证方法列表
AMRClaims []AMRClaim `json:"amr,omitempty" has_many:"amr_claims"`
// 认证等级:1、2、3次认证
AAL *string `json:"aal" db:"aal"`
RefreshedAt *time.Time `json:"refreshed_at,omitempty" db:"refreshed_at"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
IP *string `json:"ip,omitempty" db:"ip"`
Tag *string `json:"tag" db:"tag"`
}
默认情况下session不会过期,使其过期的方法包括以下几种:
- 登出:调用 supabase.auth.signOut() ,session中的数据会被清理。
- 用户reset-password。
- 用户无交互(inactive):可以在auth相关的配置中指定inactive timeout。
- 最大时长限制:可以在auth相关配置中指定最大时长Time-box user sessions。
- 用户登入其他设备:可配置session数唯一,仅一台设备使用。
session_id会被SHA的方式加密写入access-token,供后续的用户真实身份校验。
JWT
在Supabase身份验证中,Session由JWT形式的访问令牌(access_token)和刷新令牌(refresh_token)表示,其中刷新令牌是一个唯一的字符串。完成认证后,Supabase SDK会在cookie中写入JWT JSON对象。以邮件密码方式认证为例,JWT格式如下:
// AccessTokenClaims is a struct thats used for JWT claims
type AccessTokenClaims struct {
// JWT标准claims, https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
jwt.StandardClaims
Email string `json:"email"`
Phone string `json:"phone"`
// provider列表等
AppMetaData map[string]interface{} `json:"app_metadata"`
// 用户元数据
UserMetaData map[string]interface{} `json:"user_metadata"`
// authenticated
Role string `json:"role"`
// 认证等级: aal1, aal2(二次认证), aal3。
AuthenticatorAssuranceLevel string `json:"aal,omitempty"`
// 认证方式: mfa, password, magiclink等
AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"`
// 认证会话ID,用于追踪认证记录
SessionId string `json:"session_id,omitempty"`
IsAnonymous bool `json:"is_anonymous"`
}
// 生成accesstoken
claims := &hooks.AccessTokenClaims{
StandardClaims: jwt.StandardClaims{
Subject: user.ID.String(),
Audience: user.Aud,
IssuedAt: issuedAt.Unix(),
ExpiresAt: expiresAt,
Issuer: config.JWT.Issuer,
},
Email: user.GetEmail(),
Phone: user.GetPhone(),
AppMetaData: user.AppMetaData,
UserMetaData: user.UserMetaData,
Role: user.Role,
SessionId: sid,
AuthenticatorAssuranceLevel: aal.String(),
AuthenticationMethodReference: amr,
IsAnonymous: user.IsAnonymous,
}
token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims);
重点内容:
- access-token:用户凭证,有效时长可配置,建议时长不要超过1小时。可凭refresh-token定期刷新。
- refresh-token:不过期,但只允许使用一次(即refresh token rotaion),目的主要是为了防止refresh_token被窃取。同时为避免网络异常、重复渲染等情况造成的不可用,Supabase提供了Refresh Token Reuse Detection机制来保障可靠性。
- expire:access-token过期时间。
Refresh Token Reuse Detection说明
Refresh Token Reuse Detection 是一种安全机制,用于检测并管理refresh token的重复使用。在OAuth 2.0等认证协议中,refresh token通常用于获取新的access token,以便在用户不必重新登录的情况下继续访问资源。
该机制保护的内容:
- 防止Token滥用:正常情况下,一个refresh token应该只能使用一次。但是,如果严格按照这个规则执行,可能会导致一些合理场景下的问题,例如网络延迟或请求重试时,客户端可能无意中重复使用了refresh token。Refresh Token Reuse Detection允许在特定时间间隔内重复使用refresh token,从而避免了这些问题。
- 保护用户会话:如果没有适当的机制来处理refresh token的重用,可能导致用户的会话意外终止。例如,当客户端尝试使用一个已经被撤销的refresh token时,如果服务器严格遵循“一次使用”规则,它会认为这个请求是无效的,并可能终止用户的会话。Refresh Token Reuse Detection通过允许在特定条件下重用token,减少了这种情况的发生。
- 应对网络不可靠性:由于网络问题,客户端可能无法及时收到或处理服务器的响应。在这种情况下,客户端可能尝试再次使用同一个refresh token,如果服务器不支持在一定时间窗口内的重用,这将导致问题。
- 安全性:这种机制也增强了安全性,因为它可以减少因误用或滥用refresh token而导致的潜在安全风险,比如token被窃取后的滥用问题。
身份验证
(Web)SDK中提供了两个API用于验证用户身份:
- supabase.auth.getUser :该API会发起一次到Supabase· server的网络调用,在服务端去解析JWT的有效性。一般用于服务端。
- supabase.auth.getSession :从localstorage取JWT以及加密session data。用于本地的登录校验。一般用于client端非敏感权限的判断(比如判断用户是否登录)。
接入流程
个人项目中使用了Nextjs + @supabase/ssr 接入,简单介绍下使用邮件密码认证的接入流程。
前置准备
- 创建Supabase项目,申请 NEXT_PUBLIC_SUPABASE_URL 、 NEXT_PUBLIC_SUPABASE_ANON_KEY 并写入项目环境变量。
- 配置confirm地址白名单以及邮件模板,指定用户signup、password-reset等场景的confirm邮件内容以及跳转地址。
confirm地址白名单示例:
http://127.0.0.1:3000/auth/reset-password/confirm
http://127.0.0.1:3000/auth/signup/confirm
邮件模板示例:
<h2>Confirm your signup</h2>
<p>Follow this link to confirm your user:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=signup&next={{ .RedirectTo }}">Confirm your mail</a></p>
其他准备条件参考官网demo。
接入
分场景介绍几个核心场景的接入流程。
注册(signup)
这里采用需要邮箱注册的方式,且需要邮件确认。
阶段一:在supabase中配置signup注册模板,包括confirm地址。
- web请求signup页面,next server下发signup页,里面包含一个含表单的页面。
- web:用户输入signup相关字段,web端参数合法性校验,通过next action将form参数提交给next server。
- next server action:校验参数合法性,然后调用 supabase.auth.signUp() ,通过supbase 服务端给用户发送confirm邮件。返回web发送成功。[text]
http://localhost:3000/signup/confirm?token_hash=pkce_xxxxxx&type=signup同时也会下发一个 sb-xxxxxx-auth-token-code-verifier的cookie给到web。具体作何使用(注册流程跟Oauth的PKCE有何关系?)
注意:Signup需要指定redirect地址为结果页面的地址。因为OTP的第一跳地址为模板中配置的 auth/confirm ,然后才会被redirect到我们指定的redirect地址。
- web:通知已发送邮件,需要用户去邮箱确认。
阶段二:用户确认
- web:用户通过邮件点击confirm地址,query参数中包括一个一次性的token_hash以及操作类型op_type,以及redirect地址(上面的next参数)。
- next.js server action:通用的confirm action,主要校验hash-token的有效性并下发cookie。然后跳转到redirect页。
- next.js server page:注册结果展示。
问题:
- sb-xxxxxx-auth-token-code-verifier 这里的作用是什么?
NEXT_LOCALE=zh-CN;
_ga=GA1.1.1259344727.1718294160;
_ga_V8MB0D7SQD=GS1.1.1718294160.1.1.1718294177.0.0.0;
sb-isejhdrfjxyjopdeicns-auth-token-code-verifier=%22a48e28fe9386515dbb5eb9d9b50c5111d51f7c17a0fb2908e2216ba3e31cdf95f91f4653b1d26b39e1e9d7deafbb6ee1e376ec2d3a692e9d%22
code_verifier 、code_challenge 和code_challenge_method 其实是OAUTH code authorization的扩展模式PKCE所需的参数,主要目的是为了防止authorize过程中code可能被第三方恶意劫持,然后第三方使用code去换取access-token的场景。所以在OAuth的code、token两步中分别加入了额外的验证参数,确保劫持的第三方即使邮件密码认证这里应该没有实际作用。
登录(signin)
- web请求登录页,next.js server 登录page返回ssr登录页,里面包含一个含表单的页面。
- web: 用户输入sigin相关字段(email、password),web端参数合法性校验,通过next action将form参数提交给next server。
- next server action:校验参数合法性,然后调用 supabase.auth.signInWithPassword(formData) 完成登录,返回成功、失败。登录成功SDK会下发auth token到cookie中(key格式为 sb-xxxxxx-auth-token)。[javascript]
async signInWithPassword( credentials: SignInWithPasswordCredentials ): Promise<AuthTokenResponsePassword> { res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, { headers: this.headers, body: { email, password, gotrue_meta_security: { captcha_token: options?.captchaToken }, }, xform: _sessionResponsePassword, }) const { data, error } = res if (data.session) { await this._saveSession(data.session) await this._notifyAllSubscribers('SIGNED_IN', data.session) } } private async _saveSession(session: Session) { this._debug('#_saveSession()', session) // _saveSession is always called whenever a new session has been acquired // so we can safely suppress the warning returned by future getSession calls this.suppressGetSessionWarning = true await setItemAsync(this.storage, this.storageKey, session) } // Storage helpers export const setItemAsync = async ( storage: SupportedStorage, key: string, data: any ): Promise<void> => { await storage.setItem(key, JSON.stringify(data)) } - web: 展示响应结果,然后跳转到登录结果页面。
- 官网demo中,是在action下发了redirect结果。一样的效果。
- 跳转是否必须? 不是,可以不跳转。在action就已经下发了token到cookie,此时已经处于登录状态。
密码重置(reset password)
与signup流程类似,总体分位两个阶段:
阶段一:与signup一致,调用 supabase.auth.resetPasswordForEmail 发送OTP邮件。用户点击邮件后继续完成改密操作。
阶段二:经过通用confirm action的跳转后,来到改密页面。此时用户已经处于登录状态,构造form后调用action中的 supabase.auth.updateUser 即可完成密码的更新。
登出(logout)
在服务端调用signUp即可。
async function signout() {
"use server";
// getSession():
// Since the unencoded session data is retrieved from the local storage medium,
// do not rely on it as a source of trusted data on the server. It could be tampered with by the sender.
// If you need verified, trustworthy user data, call getUser instead.
const supabase = createClient()
const {
data: { session },
} = await supabase.auth.getSession()
if (session) {
await supabase.auth.signOut()
}
console.log("Signed out")
redirect("/")
}
其它细节
- 通过 verifyOtp 获得的session与通过signInWithPassword 获得的登录token/session有何不同?
没有不同,都是认证方式的一种。所以signUp、resetPassword等场景通过OTP方式登录后不需要再跳转登录页再认证一次。
- 为何OTP邮件的第一跳地址必须是通用confirm(代理),然后再跳转对应场景的结果页?
与next.js的限制有关。因为OTP链接中包含的HashToken需要转换为session,这就会涉及到cookie的写入,而在next.js中page时不允许写入cookie的,只能通过action、api或者在client component中写入。所以为了统一处理,就引入了通用confirm action。当然你也可以在结果页中强制用户手动再登录一次。
Good to know: HTTP does not allow setting cookies after streaming starts, so you must use .set() in a Server Action or Route Handler.
- 注意在middleware中增加getUser的调用,因为每次页面访问都可能会更新cookie(refreshtoken)。
- 业务逻辑中可能会有大量的getUser调用。处于性能考虑和频率限制的可能,next项目中建议使用react-cache进行一次封装。
- 如何查看API文档?参考swagger文档,可以使用petstore查看。
- supabase该如何部署?可以使用官方的免费版本,也可以自行部署。免费版本一段时间后会自动停掉(pause),需要手工recover,所以推荐使用自部署版本,功能上没有大的差异。个人项目一台腾讯云的轻量服务器足矣,部署方式可以参考这里 supbase-docker 。嫌麻烦的推荐官方付费版本。
结语
可以看到Supabase不仅提供了一个功能全面、安全可信赖的用户认证解决方案,而且其灵活的接入方式和丰富的社区支持,使其成为现代Web应用开发的优选。随着项目的深入,我们将继续探索Supabase的更多可能性,以期构建更加健壮和用户友好的应用。
参考资料