Next.js + Clerk + 微信小程序双重认证实现步骤

系统架构概览

需要实现一个双端的应用,其中Web端用Clerk,需要实现微信小程序的调用,打算用下面的架构。

1
2
3
Web端 (Clerk) ←→ 统一后端API ←→ 微信小程序 (自建认证)

用户映射数据库

1. 数据库设计

用户映射表 (user_mapping)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE user_mapping (
id SERIAL PRIMARY KEY,
clerk_user_id VARCHAR(255) UNIQUE,
wechat_openid VARCHAR(255) UNIQUE,
wechat_unionid VARCHAR(255),
unified_user_id VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255),
nickname VARCHAR(255),
avatar_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 为常用查询创建索引
CREATE INDEX idx_clerk_user_id ON user_mapping(clerk_user_id);
CREATE INDEX idx_wechat_openid ON user_mapping(wechat_openid);
CREATE INDEX idx_unified_user_id ON user_mapping(unified_user_id);

2. 后端实现

环境变量配置 (.env.local)

1
2
3
4
5
6
7
8
9
10
11
12
13
# Clerk
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...

# 微信小程序
WECHAT_APP_ID=wx...
WECHAT_APP_SECRET=...

# JWT 密钥
JWT_SECRET=your_jwt_secret_key

# 数据库
DATABASE_URL=postgresql://...

统一认证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// lib/auth-middleware.js
import { clerkClient } from '@clerk/nextjs/server';
import jwt from 'jsonwebtoken';
import { getUserByClerkId, getUserByWechatOpenid } from './db/user-mapping';

export async function authMiddleware(req, res, next) {
try {
const platform = req.headers['x-platform'] || 'web';
const authorization = req.headers.authorization;

if (!authorization) {
return res.status(401).json({ error: 'Missing authorization header' });
}

const token = authorization.replace('Bearer ', '');
let user = null;

if (platform === 'web') {
// Clerk 认证
const clerkUser = await clerkClient.users.verifyToken(token);
user = await getUserByClerkId(clerkUser.id);

if (!user) {
// 首次登录,创建映射
user = await createUserMapping({
clerkUserId: clerkUser.id,
email: clerkUser.emailAddresses[0]?.emailAddress,
nickname: clerkUser.firstName + ' ' + clerkUser.lastName,
avatarUrl: clerkUser.imageUrl
});
}
} else if (platform === 'miniprogram') {
// 微信小程序认证
const decoded = jwt.verify(token, process.env.JWT_SECRET);
user = await getUserByWechatOpenid(decoded.openid);

if (!user) {
return res.status(401).json({ error: 'User not found' });
}
}

req.user = user;
req.platform = platform;
next();
} catch (error) {
console.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}

微信登录API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// pages/api/wechat/login.js
import axios from 'axios';
import jwt from 'jsonwebtoken';
import { getUserByWechatOpenid, createUserMapping } from '../../../lib/db/user-mapping';

export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

const { code, userInfo } = req.body;

try {
// 1. 通过 code 获取 openid
const wechatResponse = await axios.get(
`https://api.weixin.qq.com/sns/jscode2session`, {
params: {
appid: process.env.WECHAT_APP_ID,
secret: process.env.WECHAT_APP_SECRET,
js_code: code,
grant_type: 'authorization_code'
}
}
);

const { openid, unionid, session_key } = wechatResponse.data;

if (!openid) {
return res.status(400).json({ error: 'Invalid wechat code' });
}

// 2. 查找或创建用户
let user = await getUserByWechatOpenid(openid);

if (!user) {
// 首次登录,创建用户映射
user = await createUserMapping({
wechatOpenid: openid,
wechatUnionid: unionid,
nickname: userInfo?.nickName || '微信用户',
avatarUrl: userInfo?.avatarUrl || ''
});
} else {
// 更新用户信息
await updateUserMapping(user.unified_user_id, {
nickname: userInfo?.nickName || user.nickname,
avatarUrl: userInfo?.avatarUrl || user.avatar_url
});
}

// 3. 生成 JWT token
const token = jwt.sign(
{
openid,
unionid,
userId: user.unified_user_id
},
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);

res.json({
success: true,
token,
user: {
id: user.unified_user_id,
nickname: user.nickname,
avatarUrl: user.avatar_url
}
});

} catch (error) {
console.error('Wechat login error:', error);
res.status(500).json({ error: 'Login failed' });
}
}

账户绑定API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// pages/api/bind-account.js
import { authMiddleware } from '../../lib/auth-middleware';
import { bindAccounts } from '../../lib/db/user-mapping';

export default async function handler(req, res) {
// 应用认证中间件
await new Promise((resolve, reject) => {
authMiddleware(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});

if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

const { bindCode, platform } = req.body;

try {
// 根据当前平台绑定另一个平台的账户
if (req.platform === 'web' && platform === 'wechat') {
// Web用户绑定微信
// bindCode 是微信小程序获取的临时code
await bindWechatToClerkUser(req.user, bindCode);
} else if (req.platform === 'miniprogram' && platform === 'web') {
// 微信用户绑定Web账户
// bindCode 是Web端生成的临时绑定码
await bindClerkToWechatUser(req.user, bindCode);
}

res.json({ success: true, message: 'Account bound successfully' });
} catch (error) {
console.error('Bind account error:', error);
res.status(500).json({ error: 'Binding failed' });
}
}

3. 数据库操作函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// lib/db/user-mapping.js
import { v4 as uuidv4 } from 'uuid';
// 假设使用 Prisma 或其他 ORM

export async function createUserMapping({
clerkUserId = null,
wechatOpenid = null,
wechatUnionid = null,
email = null,
nickname,
avatarUrl = null
}) {
const unifiedUserId = uuidv4();

// 使用你的数据库客户端
const user = await db.userMapping.create({
data: {
clerkUserId,
wechatOpenid,
wechatUnionid,
unifiedUserId,
email,
nickname,
avatarUrl
}
});

return user;
}

export async function getUserByClerkId(clerkUserId) {
return await db.userMapping.findUnique({
where: { clerkUserId }
});
}

export async function getUserByWechatOpenid(openid) {
return await db.userMapping.findUnique({
where: { wechatOpenid: openid }
});
}

export async function updateUserMapping(unifiedUserId, data) {
return await db.userMapping.update({
where: { unifiedUserId },
data: {
...data,
updatedAt: new Date()
}
});
}

4. 前端实现

Web端 (React/Next.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// hooks/useAuth.js
import { useAuth as useClerkAuth } from '@clerk/nextjs';
import { useEffect, useState } from 'react';

export function useAuth() {
const clerkAuth = useClerkAuth();
const [authToken, setAuthToken] = useState(null);

useEffect(() => {
if (clerkAuth.getToken) {
clerkAuth.getToken().then(setAuthToken);
}
}, [clerkAuth]);

return {
...clerkAuth,
authToken,
platform: 'web'
};
}

// utils/api.js
export async function apiRequest(url, options = {}) {
const { authToken } = useAuth();

return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Platform': 'web',
'Authorization': `Bearer ${authToken}`,
...options.headers
}
});
}

微信小程序端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// utils/auth.js
class WechatAuth {
constructor() {
this.token = wx.getStorageSync('wechat_token') || null;
}

async login() {
return new Promise((resolve, reject) => {
wx.login({
success: async (res) => {
try {
// 获取用户信息
const userInfo = await this.getUserProfile();

// 发送到后端验证
const response = await this.request('/api/wechat/login', {
method: 'POST',
data: {
code: res.code,
userInfo
}
});

if (response.success) {
this.token = response.token;
wx.setStorageSync('wechat_token', this.token);
resolve(response.user);
} else {
reject(new Error('Login failed'));
}
} catch (error) {
reject(error);
}
},
fail: reject
});
});
}

async getUserProfile() {
return new Promise((resolve) => {
wx.getUserProfile({
desc: '用于完善用户资料',
success: (res) => resolve(res.userInfo),
fail: () => resolve(null)
});
});
}

async request(url, options = {}) {
const baseUrl = 'https://your-domain.com';

return new Promise((resolve, reject) => {
wx.request({
url: `${baseUrl}${url}`,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
'X-Platform': 'miniprogram',
'Authorization': `Bearer ${this.token}`,
...options.headers
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data);
} else {
reject(new Error(`Request failed: ${res.statusCode}`));
}
},
fail: reject
});
});
}

logout() {
this.token = null;
wx.removeStorageSync('wechat_token');
}
}

export default new WechatAuth();

// pages/login/login.js
import WechatAuth from '../../utils/auth';

Page({
data: {
userInfo: null
},

async onLogin() {
try {
wx.showLoading({ title: '登录中...' });

const user = await WechatAuth.login();

this.setData({ userInfo: user });
wx.showToast({ title: '登录成功', icon: 'success' });

// 跳转到首页
wx.switchTab({ url: '/pages/index/index' });

} catch (error) {
console.error('Login error:', error);
wx.showToast({ title: '登录失败', icon: 'error' });
} finally {
wx.hideLoading();
}
}
});

5. 使用统一认证的API示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// pages/api/user/profile.js - 获取用户资料
import { authMiddleware } from '../../../lib/auth-middleware';

export default async function handler(req, res) {
// 应用统一认证中间件
await new Promise((resolve, reject) => {
authMiddleware(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});

if (req.method === 'GET') {
// req.user 包含统一的用户信息
res.json({
id: req.user.unified_user_id,
nickname: req.user.nickname,
avatarUrl: req.user.avatar_url,
email: req.user.email,
platform: req.platform
});
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}

6. 部署和测试

环境准备

  1. 配置微信小程序后台,获取 AppID 和 AppSecret
  2. 设置服务器域名白名单
  3. 配置 Clerk 项目设置

测试流程

  1. Web端使用 Clerk 登录
  2. 小程序端使用微信登录
  3. 测试API在两个平台下的认证
  4. 测试账户绑定功能(可选)

这个架构的优势:

  • 🔒 安全性:每个平台使用最适合的认证方式
  • 🔄 统一性:后端API统一处理,数据一致性好
  • 🚀 扩展性:易于添加新的认证平台
  • 🛠️ 维护性:职责分离,便于维护和调试

作者:Bearalise
出处:Next.js + Clerk + 微信小程序双重认证实现步骤
版权:本文版权归作者所有
转载:欢迎转载,但未经作者同意,必须保留此段声明,必须在文章中给出原文链接。

请我喝杯咖啡吧~

支付宝
微信