多租户应用通常需要支持租户使用自定义域名或者平台分配的子域名访问服务,这种场景下NextAuth的默认会话配置无法满足需求,需要针对性调整会话的域名作用范围、隔离策略等参数,才能实现正常的登录状态保持和租户数据隔离。

多租户场景下的会话核心需求
多租户应用的域名场景主要分为两类,一类是平台分配的子域名,比如租户A使用a.ippipp.com,租户B使用b.ippipp.com;另一类是租户绑定的自定义域名,比如租户A使用www.tenant-a.com,租户B使用www.tenant-b.com。这两种场景对会话管理的要求有所不同:
- 同主域下的子域名场景,通常需要支持会话在子域名之间共享,比如用户在a.ippipp.com登录后,访问b.ippipp.com也能保持登录状态,方便租户切换或者平台统一鉴权。
- 自定义域名场景,不同租户的自定义域名属于不同主域,默认情况下会话无法跨主域共享,需要保证每个租户的会话仅在其绑定的域名下有效,避免会话数据泄露到其他租户。
- 无论哪种场景,都需要保证会话的安全性,避免会话劫持、跨站请求伪造等问题,同时要支持会话的过期、刷新、销毁等基础能力。
NextAuth会话管理的基础配置
NextAuth的会话管理核心依赖session和cookies配置项,默认情况下,会话cookie的作用域是当前请求的域名,无法直接适配多租户场景。首先需要了解NextAuth中与会话相关的核心配置:
session配置项
session配置主要控制会话的存储方式和有效期,常见的配置如下:
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export const authOptions = {
session: {
// 会话存储策略,jwt模式下会话数据存在token中,database模式下存在数据库
strategy: "jwt",
// 会话最大有效期,单位秒,默认30天
maxAge: 30 * 24 * 60 * 60,
// 会话更新间隔,单位秒,默认每次请求都更新
updateAge: 24 * 60 * 60,
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "用户名", type: "text" },
password: { label: "密码", type: "password" }
},
async authorize(credentials) {
// 这里实现租户用户校验逻辑,需要关联租户域名信息
if (credentials.username === "test" && credentials.password === "123456") {
return { id: "1", name: "测试用户", tenantId: "tenant_1" }
}
return null
}
})
],
// 其他配置后续补充
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
cookies配置项
cookies配置控制NextAuth生成的cookie的属性,包括作用域、安全策略、SameSite等,这是多租户场景下调整的核心配置项。默认情况下,cookie的domain是当前请求的域名,需要手动调整来适配多域名场景。
子域名场景的会话管理实现
对于同主域下的子域名场景,比如所有租户的子域名都是*.ippipp.com格式,只需要将会话cookie的domain设置为父域ippipp.com,就可以实现会话在所有子域名下共享。
动态适配请求的域名
NextAuth的配置支持动态获取请求的域名,我们可以在cookies配置中根据请求的host来设置cookie的domain。首先需要修改NextAuth的配置文件,支持获取请求上下文:
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
// 判断是否为子域名场景,这里可以根据业务规则调整
const isSubdomain = (host) => {
return host.endsWith(".ippipp.com")
}
// 获取父域,比如a.ippipp.com返回ippipp.com
const getParentDomain = (host) => {
const parts = host.split(".")
if (parts.length >= 3) {
return parts.slice(-2).join(".")
}
return host
}
export const authOptions = {
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60,
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "用户名", type: "text" },
password: { label: "密码", type: "password" }
},
async authorize(credentials, req) {
const host = req.headers?.host || ""
// 这里可以根据host关联对应的租户信息
if (credentials.username === "test" && credentials.password === "123456") {
return {
id: "1",
name: "测试用户",
tenantId: "tenant_1",
host: host
}
}
return null
}
})
],
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
// 动态设置domain,子域名场景设置为父域
domain: (req) => {
const host = req.headers?.host || ""
if (isSubdomain(host)) {
return getParentDomain(host)
}
return host
},
secure: process.env.NODE_ENV === "production"
}
},
// 其他cookie配置类似,比如callbackUrl、csrfToken等
callbackUrl: {
name: `next-auth.callback-url`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
domain: (req) => {
const host = req.headers?.host || ""
if (isSubdomain(host)) {
return getParentDomain(host)
}
return host
},
secure: process.env.NODE_ENV === "production"
}
}
},
callbacks: {
async session({ session, token }) {
// 把租户信息放到session中,方便后续业务逻辑使用
session.tenantId = token.tenantId
session.host = token.host
return session
},
async jwt({ token, user }) {
if (user) {
token.tenantId = user.tenantId
token.host = user.host
}
return token
}
}
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
注意事项
- 子域名场景设置父域cookie时,需要确保浏览器支持,部分浏览器对cookie的domain设置有限制,不能设置为顶级域名比如.com。
- 如果不同子域名对应不同租户,还需要在业务逻辑中校验session中的host和当前请求的host是否匹配,避免租户A的会话在租户B的子域名下使用。
自定义域名场景的会话管理实现
自定义域名场景下,不同租户的域名属于不同主域,无法通过设置父域cookie实现共享,此时需要保证每个租户的会话仅在其绑定的域名下有效,同时避免会话跨域泄露。
绑定租户与域名
首先需要在系统中维护租户和域名的绑定关系,比如数据库中存储租户ID和对应的自定义域名、子域名的映射。在用户登录时,根据请求的host查询对应的租户,校验用户是否属于该租户。
配置cookie作用域为当前请求的域名
自定义域名场景下,cookie的domain直接设置为当前请求的host即可,不需要设置父域,这样会话仅在当前域名下有效。同时需要在callbacks中校验请求的host和租户绑定的域名是否一致:
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
// 模拟租户域名绑定数据,实际应该从数据库查询
const tenantDomainMap = {
"tenant_1": ["www.tenant-a.com", "a.ippipp.com"],
"tenant_2": ["www.tenant-b.com", "b.ippipp.com"]
}
// 根据host查询对应的租户ID
const getTenantIdByHost = (host) => {
for (const [tenantId, domains] of Object.entries(tenantDomainMap)) {
if (domains.includes(host)) {
return tenantId
}
}
return null
}
export const authOptions = {
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60,
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "用户名", type: "text" },
password: { label: "密码", type: "password" }
},
async authorize(credentials, req) {
const host = req.headers?.host || ""
const tenantId = getTenantIdByHost(host)
if (!tenantId) {
throw new Error("当前域名未绑定租户")
}
// 校验用户是否属于该租户
if (credentials.username === "test" && credentials.password === "123456" && tenantId === "tenant_1") {
return {
id: "1",
name: "测试用户",
tenantId: tenantId,
host: host
}
}
return null
}
})
],
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
// 自定义域名场景,domain设置为当前host
domain: (req) => {
const host = req.headers?.host || ""
return host
},
secure: process.env.NODE_ENV === "production"
}
}
},
callbacks: {
async session({ session, token }) {
session.tenantId = token.tenantId
session.host = token.host
return session
},
async jwt({ token, user }) {
if (user) {
token.tenantId = user.tenantId
token.host = user.host
}
return token
},
// 校验请求的host是否和会话绑定的host一致
async redirect({ url, baseUrl, token }) {
const host = new URL(url).host
if (token?.host && host !== token.host) {
// 如果跳转的域名和会话绑定的域名不一致,拒绝跳转
return baseUrl
}
return url
}
}
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
跨域会话处理
如果业务需要支持用户在自定义域名和平台子域名之间切换且保持登录状态,这种场景下跨主域的会话共享无法通过cookie实现,需要借助中心化的会话存储,比如使用Redis存储会话,在不同域名下通过统一的认证中心实现单点登录,NextAuth可以配置database策略将会话存储到数据库,配合单点登录逻辑实现跨域会话同步。
常见问题与解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 子域名下登录后,其他子域名无法获取会话 | cookie的domain未设置为父域 | 在cookies配置中动态将domain设置为对应的父域 |
| 自定义域名下登录失败,提示会话无效 | cookie的domain设置错误,或者请求的host和绑定的租户不匹配 | 检查cookie的domain是否为当前请求的host,校验租户和域名的绑定关系 |
| 生产环境会话无法保持 | secure配置错误,或者SameSite策略设置过严 | 生产环境开启secure为true,SameSite根据业务场景设置为lax或者none,none需要配合secure=true |
| 会话信息中缺少租户标识 | callbacks中未将租户信息同步到session和jwt中 | 在session和jwt回调中把租户相关字段写入token和session |
总结
NextAuth多租户场景下的自定义域名和子域名会话管理,核心是根据请求的host动态调整cookie的domain属性,同时配合租户域名绑定关系、会话回调校验,实现会话的隔离和合理共享。子域名场景通过设置父域cookie实现会话共享,自定义域名场景通过设置当前域名cookie实现会话隔离,复杂场景可以结合单点登录和中心化会话存储实现跨域会话同步。开发者需要根据自身业务的租户域名规则,灵活调整NextAuth的配置,同时做好会话安全校验,避免身份相关的漏洞。