使用 Supabase RLS 构建安全的 React Native 可穿戴应用
概述
健康数据包含敏感个人信息,必须严格保护。Supabase 的行级安全(Row-Level Security, RLS)提供数据库级别的访问控制,确保用户只能访问自己的数据。本文将介绍如何在 React Native 可穿戴应用中实现完整的安全方案。
安全架构设计
多层安全策略
code
┌─────────────────────────────────────────┐
│ React Native App │
├─────────────────────────────────────────┤
│ - 认证状态管理 │
│ - 数据验证 │
│ - 敏感数据加密 │
└─────────────────────────────────────────┘
↓ HTTPS
┌─────────────────────────────────────────┐
│ Supabase Client │
├─────────────────────────────────────────┤
│ - API Key 验证 │
│ - JWT Token 管理 │
│ - 请求签名 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Supabase PostgREST API │
├─────────────────────────────────────────┤
│ - JWT 解析 │
│ - 用户身份验证 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ PostgreSQL 数据库 │
├─────────────────────────────────────────┤
│ - 行级安全 (RLS) 策略 │
│ - 数据加密存储 │
│ - 审计日志 │
└─────────────────────────────────────────┘
Code collapsed
Supabase 项目设置
1. 初始化项目
code
# 安装 Supabase CLI
npm install -g supabase
# 创建新项目
supabase init
# 启动本地开发环境
supabase start
Code collapsed
2. 数据库架构设计
code
-- migrations/001_create_health_data.sql
-- 启用必要的扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- 用户配置文件表
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT UNIQUE NOT NULL,
full_name TEXT,
avatar_url TEXT,
health_profile JSONB DEFAULT '{
"birthDate": null,
"gender": null,
"height": null,
"weight": null,
"bloodType": null
}'::jsonb,
preferences JSONB DEFAULT '{
"units": "metric",
"notifications": true,
"dataSharing": false
}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 健康指标表
CREATE TABLE public.health_metrics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
metric_type TEXT NOT NULL CHECK (metric_type IN (
'heart_rate',
'blood_pressure_systolic',
'blood_pressure_diastolic',
'steps',
'calories',
'sleep_duration',
'weight',
'spo2',
'glucose'
)),
value NUMERIC NOT NULL,
unit TEXT NOT NULL,
device_id TEXT,
recorded_at TIMESTAMPTZ NOT NULL,
synced_at TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 设备表
CREATE TABLE public.devices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
device_name TEXT NOT NULL,
device_type TEXT NOT NULL,
manufacturer TEXT,
model TEXT,
serial_number TEXT UNIQUE,
last_sync TIMESTAMPTZ,
battery_level INTEGER CHECK (battery_level BETWEEN 0 AND 100),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 数据共享表(用于医生/家人访问)
CREATE TABLE public.data_shares (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
owner_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
shared_with UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
access_level TEXT NOT NULL CHECK (access_level IN ('read', 'write')),
data_types TEXT[] NOT NULL,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(owner_id, shared_with)
);
-- 审计日志表
CREATE TABLE public.audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
action TEXT NOT NULL,
table_name TEXT NOT NULL,
record_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 创建索引
CREATE INDEX idx_health_metrics_user_id ON public.health_metrics(user_id);
CREATE INDEX idx_health_metrics_type_date ON public.health_metrics(metric_type, recorded_at DESC);
CREATE INDEX idx_health_metrics_recorded_at ON public.health_metrics(recorded_at DESC);
CREATE INDEX idx_devices_user_id ON public.devices(user_id);
CREATE INDEX idx_data_shares_owner ON public.data_shares(owner_id);
CREATE INDEX idx_data_shares_recipient ON public.data_shares(shared_with);
CREATE INDEX idx_audit_logs_user_id ON public.audit_logs(user_id);
CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC);
Code collapsed
3. RLS 策略配置
code
-- migrations/002_configure_rls.sql
-- 启用 RLS
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.health_metrics ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.data_shares ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
-- Profiles 策略
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
CREATE POLICY "Users can insert own profile"
ON public.profiles FOR INSERT
WITH CHECK (auth.uid() = id);
-- Health Metrics 策略
CREATE POLICY "Users can view own metrics"
ON public.health_metrics FOR SELECT
USING (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Users can insert own metrics"
ON public.health_metrics FOR INSERT
WITH CHECK (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Users can update own metrics"
ON public.health_metrics FOR UPDATE
USING (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()))
WITH CHECK (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Users can delete own metrics"
ON public.health_metrics FOR DELETE
USING (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
-- 允许通过数据共享访问
CREATE POLICY "Users can view shared metrics"
ON public.health_metrics FOR SELECT
USING (
id IN (
SELECT unnest(shared_data_types::uuid[])
FROM public.data_shares
WHERE owner_id = health_metrics.user_id
AND shared_with = auth.uid()
AND access_level = 'read'
AND (expires_at IS NULL OR expires_at > NOW())
)
);
-- Devices 策略
CREATE POLICY "Users can view own devices"
ON public.devices FOR SELECT
USING (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Users can manage own devices"
ON public.devices FOR ALL
USING (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()))
WITH CHECK (user_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
-- Data Shares 策略
CREATE POLICY "Owners can manage shares"
ON public.data_shares FOR ALL
USING (owner_id = (SELECT id FROM public.profiles WHERE id = auth.uid()))
WITH CHECK (owner_id = (SELECT id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Recipients can view shares"
ON public.data_shares FOR SELECT
USING (shared_with = auth.uid());
-- Audit Logs 策略
CREATE POLICY "Users can view own audit logs"
ON public.audit_logs FOR SELECT
USING (user_id = auth.uid());
CREATE POLICY "System can insert audit logs"
ON public.audit_logs FOR INSERT
WITH CHECK (true);
-- 创建自动审计触发器
CREATE OR REPLACE FUNCTION public.log_audit_changes()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.audit_logs (
user_id,
action,
table_name,
record_id,
old_values,
new_values
) VALUES (
auth.uid(),
TG_OP,
TG_TABLE_NAME,
CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END,
CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN row_to_json(OLD) END,
CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN row_to_json(NEW) END
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 为健康指标表创建触发器
CREATE TRIGGER audit_health_metrics
AFTER INSERT OR UPDATE OR DELETE ON public.health_metrics
FOR EACH ROW EXECUTE FUNCTION public.log_audit_changes();
Code collapsed
React Native 集成
1. 安装依赖
code
npm install @supabase/supabase-js
npm install react-native-url-polyfill
Code collapsed
2. Supabase 客户端配置
code
// lib/supabase/client.ts
import 'react-native-url-polyfill/auto';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SecureStore } from 'expo-secure-store';
// 从环境变量获取
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
// 自定义存储适配器(使用 SecureStore 存储敏感数据)
const customStorageAdapter = {
getItem: (key: string) => {
return SecureStore.getItemAsync(key);
},
setItem: (key: string, value: string) => {
return SecureStore.setItemAsync(key, value);
},
removeItem: (key: string) => {
return SecureStore.deleteItemAsync(key);
},
};
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: customStorageAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
db: {
schema: 'public',
},
global: {
headers: {
'X-Client-Info': 'wearable-health-app',
},
},
});
// 类型定义
export interface Database {
public: {
Tables: {
profiles: {
Row: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
health_profile: {
birthDate: string | null;
gender: string | null;
height: number | null;
weight: number | null;
bloodType: string | null;
};
preferences: {
units: 'metric' | 'imperial';
notifications: boolean;
dataSharing: boolean;
};
created_at: string;
updated_at: string;
};
Insert: Omit<Database['public']['Tables']['profiles']['Row'], 'id' | 'created_at' | 'updated_at'>;
Update: Partial<Database['public']['Tables']['profiles']['Insert']>;
};
health_metrics: {
Row: {
id: string;
user_id: string;
metric_type: 'heart_rate' | 'blood_pressure_systolic' | 'blood_pressure_diastolic' | 'steps' | 'calories' | 'sleep_duration' | 'weight' | 'spo2' | 'glucose';
value: number;
unit: string;
device_id: string | null;
recorded_at: string;
synced_at: string;
metadata: Record<string, any>;
created_at: string;
};
Insert: Omit<Database['public']['Tables']['health_metrics']['Row'], 'id' | 'synced_at' | 'created_at'>;
Update: Partial<Database['public']['Tables']['health_metrics']['Insert']>;
};
// ... 其他表
};
};
}
Code collapsed
3. 认证管理
code
// lib/supabase/auth.ts
import { supabase } from './client';
import type { AuthError, User } from '@supabase/supabase-js';
export interface SignUpData {
email: string;
password: string;
fullName: string;
}
export interface SignInData {
email: string;
password: string;
}
export class AuthService {
/**
* 注册新用户
*/
static async signUp(data: SignUpData): Promise<{ user: User | null; error: AuthError | null }> {
const { data: authData, error } = await supabase.auth.signUp({
email: data.email,
password: data.password,
options: {
data: {
full_name: data.fullName,
},
},
});
if (error) return { user: null, error };
// 创建用户配置文件
if (authData.user) {
const { error: profileError } = await supabase
.from('profiles')
.insert({
id: authData.user.id,
email: data.email,
full_name: data.fullName,
});
if (profileError) {
console.error('Profile creation failed:', profileError);
// 可以选择回滚认证
}
}
return { user: authData.user, error: null };
}
/**
* 用户登录
*/
static async signIn(data: SignInData): Promise<{ user: User | null; error: AuthError | null }> {
const { data: authData, error } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
});
return { user: authData.user, error };
}
/**
* 用户登出
*/
static async signOut(): Promise<{ error: AuthError | null }> {
const { error } = await supabase.auth.signOut();
return { error };
}
/**
* 获取当前用户
*/
static async getCurrentUser(): Promise<User | null> {
const { data: { user } } = await supabase.auth.getUser();
return user;
}
/**
* 监听认证状态变化
*/
static onAuthStateChange(callback: (event: string, session: any) => void) {
return supabase.auth.onAuthStateChange(callback);
}
/**
* 重置密码
*/
static async resetPassword(email: string): Promise<{ error: AuthError | null }> {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'wearableapp://reset-password',
});
return { error };
}
/**
* 更新密码
*/
static async updatePassword(newPassword: string): Promise<{ error: AuthError | null }> {
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
return { error };
}
}
Code collapsed
4. 健康数据服务
code
// lib/supabase/healthData.ts
import { supabase, type Database } from './client';
import { PostgrestError } from '@supabase/supabase-js';
export type HealthMetric = Database['public']['Tables']['health_metrics']['Row'];
export type HealthMetricInsert = Database['public']['Tables']['health_metrics']['Insert'];
export class HealthDataService {
/**
* 批量插入健康指标
*/
static async batchInsertMetrics(
metrics: Omit<HealthMetricInsert, 'user_id'>[]
): Promise<{ data: HealthMetric[] | null; error: PostgrestError | null }> {
// 添加用户 ID
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { data: null, error: { message: 'Not authenticated', details: '', hint: '', code: '401' } };
const metricsWithUserId = metrics.map(metric => ({
...metric,
user_id: user.id,
}));
const { data, error } = await supabase
.from('health_metrics')
.insert(metricsWithUserId)
.select();
return { data, error };
}
/**
* 获取指定日期范围的健康指标
*/
static async getMetricsByDateRange(
metricType: HealthMetric['metric_type'],
startDate: Date,
endDate: Date
): Promise<{ data: HealthMetric[] | null; error: PostgrestError | null }> {
const { data, error } = await supabase
.from('health_metrics')
.select('*')
.eq('metric_type', metricType)
.gte('recorded_at', startDate.toISOString())
.lte('recorded_at', endDate.toISOString())
.order('recorded_at', { ascending: true });
return { data, error };
}
/**
* 获取最新指标值
*/
static async getLatestMetric(
metricType: HealthMetric['metric_type']
): Promise<{ data: HealthMetric | null; error: PostgrestError | null }> {
const { data, error } = await supabase
.from('health_metrics')
.select('*')
.eq('metric_type', metricType)
.order('recorded_at', { ascending: false })
.limit(1)
.single();
return { data: data as HealthMetric | null, error };
}
/**
* 获取每日汇总
*/
static async getDailySummary(
metricType: HealthMetric['metric_type'],
days: number = 30
): Promise<{ data: Array<{ date: string; value: number }> | null; error: PostgrestError | null }> {
const { data, error } = await supabase
.rpc('get_daily_metric_summary', {
p_metric_type: metricType,
p_days: days,
});
return { data, error };
}
/**
* 删除旧数据(数据保留策略)
*/
static async deleteOldMetrics(daysToKeep: number = 365): Promise<{ error: PostgrestError | null }> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const { error } = await supabase
.from('health_metrics')
.delete()
.lt('recorded_at', cutoffDate.toISOString());
return { error };
}
/**
* 同步本地数据到服务器
*/
static async syncLocalData(localMetrics: Array<{
metric_type: HealthMetric['metric_type'];
value: number;
unit: string;
device_id?: string;
recorded_at: string;
}>) {
// 批量上传
const batchSize = 100;
const results = [];
for (let i = 0; i < localMetrics.length; i += batchSize) {
const batch = localMetrics.slice(i, i + batchSize);
const { data, error } = await this.batchInsertMetrics(batch);
if (error) {
console.error('Sync batch failed:', error);
}
results.push({ data, error });
}
return results;
}
}
Code collapsed
5. 数据共享管理
code
// lib/supabase/dataSharing.ts
import { supabase } from './client';
import type { PostgrestError } from '@supabase/supabase-js';
export interface DataShare {
id: string;
owner_id: string;
shared_with: string;
access_level: 'read' | 'write';
data_types: string[];
expires_at: string | null;
created_at: string;
}
export class DataSharingService {
/**
* 创建数据共享
*/
static async createShare(
recipientEmail: string,
accessLevel: 'read' | 'write',
dataTypes: string[],
expiresAt?: Date
): Promise<{ data: DataShare | null; error: PostgrestError | null }> {
// 获取接收者用户 ID
const { data: { users } } = await supabase.auth.admin.listUsers();
const recipient = users.find(u => u.email === recipientEmail);
if (!recipient) {
return { data: null, error: { message: 'User not found', details: '', hint: '', code: '404' } };
}
const { data, error } = await supabase
.from('data_shares')
.insert({
shared_with: recipient.id,
access_level: accessLevel,
data_types: dataTypes,
expires_at: expiresAt?.toISOString() || null,
})
.select()
.single();
return { data, error };
}
/**
* 获取我创建的共享
*/
static async getMyShares(): Promise<{ data: DataShare[] | null; error: PostgrestError | null }> {
const { data, error } = await supabase
.from('data_shares')
.select('*, shared_with:auth.users(email, full_name)')
.order('created_at', { ascending: false });
return { data, error };
}
/**
* 获取与我的共享
*/
static async getSharedWithMe(): Promise<{ data: DataShare[] | null; error: PostgrestError | null }> {
const { data, error } = await supabase
.from('data_shares')
.select('*, owner:profiles(email, full_name)')
.eq('shared_with', supabase.auth.user()?.id)
.order('created_at', { ascending: false });
return { data, error };
}
/**
* 撤销共享
*/
static async revokeShare(shareId: string): Promise<{ error: PostgrestError | null }> {
const { error } = await supabase
.from('data_shares')
.delete()
.eq('id', shareId);
return { error };
}
}
Code collapsed
6. React Hooks 集成
code
// hooks/useSupabaseAuth.ts
import { useState, useEffect } from 'react';
import { AuthService } from '@/lib/supabase/auth';
import type { User } from '@supabase/supabase-js';
export function useSupabaseAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 获取初始会话
AuthService.getCurrentUser().then(setUser);
setLoading(false);
// 监听认证状态变化
const { data: { subscription } } = AuthService.onAuthStateChange((event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, []);
const signIn = async (email: string, password: string) => {
const { user, error } = await AuthService.signIn({ email, password });
if (user) setUser(user);
return { user, error };
};
const signUp = async (email: string, password: string, fullName: string) => {
return AuthService.signUp({ email, password, fullName });
};
const signOut = async () => {
const { error } = await AuthService.signOut();
if (!error) setUser(null);
return { error };
};
return {
user,
loading,
signIn,
signUp,
signOut,
};
}
Code collapsed
安全最佳实践
1. 敏感数据加密
code
// lib/encryption.ts
import CryptoJS from 'crypto-js';
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!;
export class DataEncryption {
/**
* 加密敏感数据
*/
static encrypt(data: string): string {
return CryptoJS.AES.encrypt(data, ENCRYPTION_KEY).toString();
}
/**
* 解密数据
*/
static decrypt(encryptedData: string): string {
const bytes = CryptoJS.AES.decrypt(encryptedData, ENCRYPTION_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
}
/**
* 加密健康数据对象
*/
static encryptHealthData(data: Record<string, any>): Record<string, any> {
const sensitiveFields = ['glucose', 'blood_pressure', 'weight'];
return Object.entries(data).reduce((acc, [key, value]) => {
if (sensitiveFields.includes(key) && typeof value === 'string') {
acc[key] = this.encrypt(value);
} else {
acc[key] = value;
}
return acc;
}, {} as Record<string, any>);
}
}
Code collapsed
2. API 密钥管理
code
// lib/env.ts
// 从 .env 文件加载环境变量
const ENV = {
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL,
supabaseAnonKey: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
// 不要在客户端存储 service_role 密钥
};
// 验证必要的环境变量
if (!ENV.supabaseUrl || !ENV.supabaseAnonKey) {
throw new Error('Missing required environment variables');
}
export default ENV;
Code collapsed