Skip to content

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 via ADMIN_PASSWORD env var. Middleware protects /admin/* and /api/admin/*.
  • User auth: None. No registration, no login, no user model.
  • Supabase client: @supabase/supabase-js already installed, basic client in src/lib/supabase.ts, but not used for auth.

Goal

为所有用户提供注册/登录能力,支持:

  1. Email + 密码 -- 传统注册登录
  2. Google OAuth -- 一键登录
  3. 匿名会话 -- 未注册用户可使用聊天等功能,后续可绑定为正式账户
  4. Admin 保持现有机制 -- 不受影响,后续可迁移

2. Authentication Methods

2.1 Email + Password

FlowDescription
Sign up提交 email + password → Supabase 发送确认邮件 → 用户点击链接 → 激活账户
Sign in提交 email + password → Supabase 验证 → 返回 session
Password reset提交 email → Supabase 发送重置邮件 → 用户点链接 → 设置新密码
Sign out清除 session cookie

2.2 Google OAuth

StepAction
1用户点击 "Sign in with Google" 按钮
2跳转 Google 授权页面
3授权后回调到 Supabase: https://<project-ref>.supabase.co/auth/v1/callback
4Supabase 创建/匹配用户,生成 session
5重定向到 /auth/callback → 交换 code → 设置 cookie → 重定向到目标页

Supabase Dashboard 配置:

  1. Authentication → Providers → Google → Enable
  2. 填入 Google Cloud Console 生成的 Client ID 和 Client Secret
  3. Authentication → URL Configuration → 设置 Site URL 和 Redirect URLs

Google Cloud Console 配置:

  1. 创建 OAuth 2.0 Client ID (Web application)
  2. Authorized redirect URI: https://hsmxrctokwgdxauhirec.supabase.co/auth/v1/callback
  3. 开发环境额外添加 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 methods
typescript
'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 out

5.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

@supabase/ssr 自动处理 cookie 设置:

  • HttpOnly: Yes
  • SameSite: Lax
  • Secure: 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.ts to integrate Supabase session refresh
  • [ ] Supabase migration: profiles table + trigger
  • [ ] Create AuthProvider context
  • [ ] Create /login page (email + Google)
  • [ ] Create /signup page (email + Google)
  • [ ] Create /auth/callback + /auth/confirm route handlers
  • [ ] Create UserMenu component in Navbar
  • [ ] Update layout.tsx with AuthProvider

Phase 2: Account Management

  • [ ] Create /account page (view/edit profile)
  • [ ] Create /forgot-password page
  • [ ] Create /update-password page
  • [ ] 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

WellChina 内部文档 · 基于 VitePress