主题
WellChina Authentication System Design
User registration, login, and session management based on Supabase Auth.
1. Background
Current State
- Admin auth: Simple password cookie (
admin_token), single shared password viaADMIN_PASSWORDenv var. Middleware protects/admin/*and/api/admin/*. - User auth: None. No registration, no login, no user model.
- Supabase client:
@supabase/supabase-jsalready installed, basic client insrc/lib/supabase.ts, but not used for auth.
Goal
为所有用户提供注册/登录能力,支持:
- Email + 密码 -- 传统注册登录
- Google OAuth -- 一键登录
- 匿名会话 -- 未注册用户可使用聊天等功能,后续可绑定为正式账户
- Admin 保持现有机制 -- 不受影响,后续可迁移
2. Authentication Methods
2.1 Email + Password
| Flow | Description |
|---|---|
| Sign up | 提交 email + password → Supabase 发送确认邮件 → 用户点击链接 → 激活账户 |
| Sign in | 提交 email + password → Supabase 验证 → 返回 session |
| Password reset | 提交 email → Supabase 发送重置邮件 → 用户点链接 → 设置新密码 |
| Sign out | 清除 session cookie |
2.2 Google OAuth
| Step | Action |
|---|---|
| 1 | 用户点击 "Sign in with Google" 按钮 |
| 2 | 跳转 Google 授权页面 |
| 3 | 授权后回调到 Supabase: https://<project-ref>.supabase.co/auth/v1/callback |
| 4 | Supabase 创建/匹配用户,生成 session |
| 5 | 重定向到 /auth/callback → 交换 code → 设置 cookie → 重定向到目标页 |
Supabase Dashboard 配置:
- Authentication → Providers → Google → Enable
- 填入 Google Cloud Console 生成的 Client ID 和 Client Secret
- Authentication → URL Configuration → 设置 Site URL 和 Redirect URLs
Google Cloud Console 配置:
- 创建 OAuth 2.0 Client ID (Web application)
- Authorized redirect URI:
https://hsmxrctokwgdxauhirec.supabase.co/auth/v1/callback - 开发环境额外添加 localhost redirect
2.3 Anonymous Auth
为未注册用户提供临时身份,主要服务于聊天系统。
访客打开聊天 → signInAnonymously() → 获得 JWT { is_anonymous: true }
│
├── 可以实时聊天、发送消息
├── 数据通过 user ID 关联
│
├── 用户决定注册 → linkIdentity({ provider: 'google' })
│ 或 updateUser({ email, password })
│ → 同一个 user ID,数据保留
│
└── 用户清理浏览器 → session 丢失 → 历史不可恢复重要提示: 匿名会话依赖浏览器 cookie。如果用户清除浏览器数据,匿名身份将丢失,聊天历史无法找回。UI 需要明确提示这一点并鼓励注册。
3. Architecture
3.1 Package Setup
bash
npm install @supabase/ssr
# @supabase/supabase-js already installed需要 @supabase/ssr v0.10+ 配合 @supabase/supabase-js v2.100+。
3.2 Supabase Client Files
替换现有的 src/lib/supabase.ts,拆分为三个文件:
src/lib/supabase/
client.ts -- Browser client (Client Components)
server.ts -- Server client (Server Components, Route Handlers)
middleware.ts -- Middleware client (session refresh + route protection)client.ts -- Browser singleton:
typescript
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!
)
}server.ts -- 每次请求新建(读取 cookies):
typescript
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component 中无法写 cookie,忽略
}
},
},
}
)
}middleware.ts -- Session 刷新 + 路由保护:
typescript
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// 验证并刷新 token(关键:不能省略)
const { data: { user } } = await supabase.auth.getUser()
return { supabaseResponse, user }
}3.3 Middleware Integration
现有 src/middleware.ts 需要重构,同时保留 admin 密码认证和新增 Supabase Auth:
typescript
import { NextResponse, type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. Supabase session refresh(所有请求)
const { supabaseResponse, user } = await updateSession(request)
// 2. Admin routes: 保留现有密码认证
if (pathname.startsWith('/admin') || pathname.startsWith('/api/admin')) {
// 排除登录页和认证 API
if (pathname === '/admin/login' || pathname === '/api/admin/auth') {
return supabaseResponse
}
const token = request.cookies.get('admin_token')?.value
const adminPassword = process.env.ADMIN_PASSWORD || 'wellchina-admin'
if (token !== adminPassword) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.redirect(new URL('/admin/login', request.url))
}
}
// 3. Agent routes: 需要 Supabase Auth + agent 角色(聊天系统)
if (pathname.startsWith('/agent')) {
if (pathname === '/agent/login') return supabaseResponse
if (!user) {
return NextResponse.redirect(new URL('/agent/login', request.url))
}
}
// 4. Protected user routes(如 /account)
if (pathname.startsWith('/account')) {
if (!user || user.is_anonymous) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}3.4 Auth Callback Routes
OAuth + Email 确认回调 (src/app/auth/callback/route.ts):
typescript
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth`)
}Email 确认回调 (src/app/auth/confirm/route.ts):
typescript
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) {
return NextResponse.redirect(new URL('/account', request.url))
}
}
return NextResponse.redirect(new URL('/login?error=confirm', request.url))
}4. User Profile
Supabase Auth 管理 auth.users 表(内置,不可直接修改 schema)。我们在 public schema 创建一个 profile 表存储额外信息:
sql
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text,
avatar_url text,
nationality text,
phone text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
alter table public.profiles enable row level security;
-- 用户只能读写自己的 profile
create policy "Users read own profile" on profiles
for select using (auth.uid() = id);
create policy "Users update own profile" on profiles
for update using (auth.uid() = id);
-- 注册时自动创建 profile(通过 trigger)
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'name'),
new.raw_user_meta_data->>'avatar_url'
);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();5. Frontend Pages & Components
5.1 New Routes
src/app/
login/page.tsx -- 登录页(email + Google)
signup/page.tsx -- 注册页(email + Google)
forgot-password/page.tsx -- 忘记密码
update-password/page.tsx -- 重置密码(从邮件链接进入)
account/page.tsx -- 用户个人信息页
auth/
callback/route.ts -- OAuth 回调
confirm/route.ts -- Email 确认回调5.2 New Components
src/components/auth/
LoginForm.tsx -- Email + password 登录表单
SignupForm.tsx -- Email + password 注册表单
GoogleSignInButton.tsx -- Google OAuth 按钮
ForgotPasswordForm.tsx -- 忘记密码表单
UpdatePasswordForm.tsx -- 新密码表单
AuthGuard.tsx -- 客户端 auth 检查 wrapper
src/components/
UserMenu.tsx -- Navbar 用户菜单(头像/登录按钮)5.3 Auth Context
src/contexts/
AuthContext.tsx -- 提供 user state + auth methodstypescript
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { User } from '@supabase/supabase-js'
type AuthContextType = {
user: User | null
isAnonymous: boolean
isLoading: boolean
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextType>(...)
export function AuthProvider({ children, initialUser }: { children: React.ReactNode; initialUser: User | null }) {
const [user, setUser] = useState<User | null>(initialUser)
const supabase = createClient()
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null)
}
)
return () => subscription.unsubscribe()
}, [])
// ...
}在 layout.tsx 中,Server Component 获取 user 并传给 AuthProvider:
tsx
// src/app/layout.tsx
import { createClient } from '@/lib/supabase/server'
export default async function RootLayout({ children }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return (
<html>
<body>
<AuthProvider initialUser={user}>
{/* existing providers */}
{children}
</AuthProvider>
</body>
</html>
)
}5.4 Navbar Integration
┌─────────────────────────────────────────────────────┐
│ Logo Hospitals Procedures Pricing Guides │
│ [User Menu]│
└─────────────────────────────────────────────────────┘
User Menu (logged out): "Log in" button
User Menu (logged in): Avatar dropdown → Account / Sign out5.5 Login Page Layout
┌─────────────────────────────────────────┐
│ │
│ Welcome to WellChina │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Continue with Google [G] │ │
│ └─────────────────────────────────┘ │
│ │
│ ──────────── or ──────────── │
│ │
│ Email │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ Password │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
│ [Forgot password?] │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Log in │ │
│ └─────────────────────────────────┘ │
│ │
│ Don't have an account? Sign up │
│ │
└─────────────────────────────────────────┘6. Security Considerations
Server 端验证
⚠️ 关键原则:Server 端永远用 getUser(),不要用 getSession()
getSession() -- 只读 cookie,不验证 JWT → 可被伪造
getUser() -- 向 Supabase Auth 服务器验证 JWT → 可信RLS 策略
所有 public schema 表启用 RLS。聊天表的 RLS 策略见 chat-system-design.md。
匿名用户限制
- 匿名用户使用
authenticated角色,需要在 RLS 中通过auth.jwt()->>'is_anonymous'区分 - 限制匿名用户的写操作范围(只能操作聊天相关表)
- Rate limit: 30 次匿名注册/小时/IP
Cookie 安全
@supabase/ssr 自动处理 cookie 设置:
HttpOnly: YesSameSite: LaxSecure: Yes (production)- Cookie name:
sb-<project-ref>-auth-token
7. Environment Variables
无需新增环境变量。现有的即可:
env
# 已有 -- 用于 Supabase Auth
NEXT_PUBLIC_SUPABASE_URL=https://hsmxrctokwgdxauhirec.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_...
# 已有 -- Google OAuth 凭据在 Supabase Dashboard 配置,不需要在本地 env 设置Google OAuth 的 Client ID 和 Client Secret 在 Supabase Dashboard 中配置,不暴露到代码仓库。
8. Supabase Dashboard Configuration Checklist
- [ ] Authentication → Providers → Email → Enabled (default)
- [ ] Authentication → Providers → Google → Enable, fill Client ID & Secret
- [ ] Authentication → Providers → Anonymous Sign-Ins → Enable
- [ ] Authentication → Providers → Manual Linking → Enable
- [ ] Authentication → URL Configuration → Site URL: production URL
- [ ] Authentication → URL Configuration → Redirect URLs: add localhost:3000, production domain
- [ ] Authentication → Email Templates → Customize confirm/reset emails (set redirect to
/auth/confirm) - [ ] Authentication → Rate Limits → Review defaults
9. Migration from Current Admin Auth
现有 admin 密码认证继续工作,不受影响。两套系统并存:
/admin/* → admin_token cookie (existing)
/agent/* → Supabase Auth (new, for chat agents)
/account/* → Supabase Auth (new, for users)
/login → Supabase Auth (new)
Others → No auth required未来可选迁移: 将 admin 也迁移到 Supabase Auth,通过 profiles 表的 role 字段或 Supabase 的自定义 claims 区分 admin/agent/user。但这不是 Phase 1 范围。
10. Implementation Phases
Phase 1: Core Auth
- [ ] Install
@supabase/ssr - [ ] Create
src/lib/supabase/{client,server,middleware}.ts - [ ] Refactor
src/middleware.tsto integrate Supabase session refresh - [ ] Supabase migration:
profilestable + trigger - [ ] Create
AuthProvidercontext - [ ] Create
/loginpage (email + Google) - [ ] Create
/signuppage (email + Google) - [ ] Create
/auth/callback+/auth/confirmroute handlers - [ ] Create
UserMenucomponent in Navbar - [ ] Update
layout.tsxwith AuthProvider
Phase 2: Account Management
- [ ] Create
/accountpage (view/edit profile) - [ ] Create
/forgot-passwordpage - [ ] Create
/update-passwordpage - [ ] Anonymous → permanent account linking UI (in chat widget)
Phase 3: Admin Migration (Optional)
- [ ] Migrate admin auth to Supabase Auth
- [ ] Add role-based access control
- [ ] Audit logging
11. File Structure (New & Modified Files)
New files:
src/lib/supabase/
client.ts -- Browser Supabase client
server.ts -- Server Supabase client
middleware.ts -- Middleware session helper
src/contexts/
AuthContext.tsx -- Auth state provider
src/components/auth/
LoginForm.tsx
SignupForm.tsx
GoogleSignInButton.tsx
ForgotPasswordForm.tsx
UpdatePasswordForm.tsx
src/components/UserMenu.tsx
src/app/login/page.tsx
src/app/signup/page.tsx
src/app/forgot-password/page.tsx
src/app/update-password/page.tsx
src/app/account/page.tsx
src/app/auth/callback/route.ts
src/app/auth/confirm/route.ts
Modified files:
src/middleware.ts -- Add Supabase session refresh
src/app/layout.tsx -- Add AuthProvider
src/components/Navbar.tsx -- Add UserMenu
src/lib/supabase.ts -- Deprecated, replaced by supabase/ directory
Deleted files:
src/lib/supabase.ts -- Replaced by src/lib/supabase/client.ts