363 lines
11 KiB
Markdown
363 lines
11 KiB
Markdown
# 登录与鉴权
|
||
|
||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
|
||
|
||
## 登录流程概览
|
||
|
||
```
|
||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||
│ 小程序 │ │ 开发者服务器 │ │ 微信服务器 │
|
||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||
│ │ │
|
||
│ 1. wx.login() │ │
|
||
│ ──────────────>│ │
|
||
│ 返回 code │ │
|
||
│ │ │
|
||
│ 2. wx.request │ │
|
||
│ 发送 code │ │
|
||
│ ──────────────>│ │
|
||
│ │ 3. code2Session│
|
||
│ │ ──────────────>│
|
||
│ │ 返回 openid + │
|
||
│ │ session_key │
|
||
│ │ <──────────────│
|
||
│ │ │
|
||
│ │ 4. 生成自定义 │
|
||
│ │ 登录态 token │
|
||
│ │ │
|
||
│ 5. 返回 token │ │
|
||
│ <──────────────│ │
|
||
│ │ │
|
||
│ 6. 后续请求 │ │
|
||
│ 携带 token │ │
|
||
│ ──────────────>│ │
|
||
│ │ 7. 校验 token │
|
||
│ │ 查 openid │
|
||
```
|
||
|
||
## 前端登录实现
|
||
|
||
### 基本登录
|
||
|
||
```javascript
|
||
// app.js 或 utils/auth.js
|
||
async function login() {
|
||
// 1. 调用 wx.login 获取 code
|
||
const { code } = await wx.login()
|
||
|
||
// 2. 发送 code 到后端换取 token
|
||
const res = await wx.request({
|
||
url: 'https://your-server.com/api/auth/login',
|
||
method: 'POST',
|
||
data: { code }
|
||
})
|
||
|
||
if (res.data.token) {
|
||
// 3. 存储 token
|
||
wx.setStorageSync('token', res.data.token)
|
||
return res.data
|
||
} else {
|
||
throw new Error('登录失败')
|
||
}
|
||
}
|
||
```
|
||
|
||
### 检查登录态
|
||
|
||
```javascript
|
||
// 检查 session_key 是否过期
|
||
function checkSession() {
|
||
return new Promise((resolve, reject) => {
|
||
wx.checkSession({
|
||
success: () => resolve(true), // session_key 未过期
|
||
fail: () => resolve(false) // session_key 已过期,需重新 login
|
||
})
|
||
})
|
||
}
|
||
|
||
// 启动时检查
|
||
async function checkAndLogin() {
|
||
const token = wx.getStorageSync('token')
|
||
if (!token) {
|
||
return await login()
|
||
}
|
||
|
||
const isSessionValid = await checkSession()
|
||
if (!isSessionValid) {
|
||
// session_key 过期,重新登录
|
||
return await login()
|
||
}
|
||
|
||
return { token }
|
||
}
|
||
```
|
||
|
||
### 封装请求(自动携带 token)
|
||
|
||
```typescript
|
||
// utils/request.ts
|
||
interface RequestOptions {
|
||
url: string
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||
data?: any
|
||
header?: Record<string, string>
|
||
}
|
||
|
||
function request(options: RequestOptions): Promise<any> {
|
||
const token = wx.getStorageSync('token')
|
||
return new Promise((resolve, reject) => {
|
||
wx.request({
|
||
...options,
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': token ? `Bearer ${token}` : '',
|
||
...options.header
|
||
},
|
||
success(res) {
|
||
if (res.statusCode === 401) {
|
||
// token 过期,重新登录
|
||
wx.removeStorageSync('token')
|
||
login().then(() => {
|
||
// 重试原请求
|
||
request(options).then(resolve).catch(reject)
|
||
})
|
||
return
|
||
}
|
||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||
resolve(res.data)
|
||
} else {
|
||
reject(res)
|
||
}
|
||
},
|
||
fail: reject
|
||
})
|
||
})
|
||
}
|
||
```
|
||
|
||
## 后端登录实现(Python / FastAPI 示例)
|
||
|
||
```python
|
||
import httpx
|
||
from fastapi import APIRouter, HTTPException, Depends
|
||
from datetime import datetime, timedelta
|
||
import jwt
|
||
|
||
router = APIRouter()
|
||
|
||
APPID = "your_appid"
|
||
SECRET = "your_secret"
|
||
JWT_SECRET = "your_jwt_secret"
|
||
|
||
@router.post("/auth/login")
|
||
async def login(code: str):
|
||
# 1. 用 code 换取 openid + session_key
|
||
url = "https://api.weixin.qq.com/sns/jscode2session"
|
||
params = {
|
||
"appid": APPID,
|
||
"secret": SECRET,
|
||
"js_code": code,
|
||
"grant_type": "authorization_code"
|
||
}
|
||
async with httpx.AsyncClient() as client:
|
||
resp = await client.get(url, params=params)
|
||
data = resp.json()
|
||
|
||
if "errcode" in data and data["errcode"] != 0:
|
||
raise HTTPException(400, f"微信登录失败: {data.get('errmsg')}")
|
||
|
||
openid = data["openid"]
|
||
session_key = data["session_key"]
|
||
unionid = data.get("unionid")
|
||
|
||
# 2. 查找或创建用户
|
||
user = await find_or_create_user(openid, unionid)
|
||
|
||
# 3. 生成 JWT token
|
||
token = jwt.encode({
|
||
"sub": str(user.id),
|
||
"openid": openid,
|
||
"exp": datetime.utcnow() + timedelta(days=7)
|
||
}, JWT_SECRET, algorithm="HS256")
|
||
|
||
# 4. 缓存 session_key(用于后续解密)
|
||
await cache_session_key(openid, session_key)
|
||
|
||
return {"token": token, "user": user.to_dict()}
|
||
```
|
||
|
||
## 获取手机号
|
||
|
||
### 前端
|
||
|
||
```xml
|
||
<!-- 基础库 2.21.2+ 推荐用法 -->
|
||
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber">
|
||
授权手机号
|
||
</button>
|
||
```
|
||
|
||
```javascript
|
||
Page({
|
||
getPhoneNumber(e) {
|
||
if (e.detail.errMsg === 'getPhoneNumber:ok') {
|
||
const code = e.detail.code // 动态令牌(新版)
|
||
// 发送 code 到后端
|
||
wx.request({
|
||
url: 'https://your-server.com/api/auth/phone',
|
||
method: 'POST',
|
||
data: { code },
|
||
success(res) {
|
||
console.log('手机号:', res.data.phoneNumber)
|
||
}
|
||
})
|
||
} else {
|
||
console.log('用户拒绝授权')
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 后端
|
||
|
||
```python
|
||
@router.post("/auth/phone")
|
||
async def get_phone(code: str, token: str = Depends(get_current_token)):
|
||
access_token = await get_access_token()
|
||
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
resp = await client.post(url, json={"code": code})
|
||
data = resp.json()
|
||
|
||
if data.get("errcode") != 0:
|
||
raise HTTPException(400, f"获取手机号失败: {data.get('errmsg')}")
|
||
|
||
phone_info = data["phone_info"]
|
||
phone_number = phone_info["phoneNumber"]
|
||
|
||
# 更新用户手机号
|
||
await update_user_phone(token.openid, phone_number)
|
||
|
||
return {"phoneNumber": phone_number}
|
||
```
|
||
|
||
## 用户信息获取(当前方案)
|
||
|
||
`wx.getUserProfile` 已于基础库 2.27.1 废弃。当前获取用户头像和昵称的方式:
|
||
|
||
### 头像昵称填写能力
|
||
|
||
```xml
|
||
<!-- 头像选择 -->
|
||
<button open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||
<image src="{{avatarUrl}}" class="avatar"/>
|
||
</button>
|
||
|
||
<!-- 昵称填写(type="nickname" 自动弹出微信昵称) -->
|
||
<input type="nickname" placeholder="请输入昵称" bindchange="onNicknameChange"/>
|
||
```
|
||
|
||
```javascript
|
||
Page({
|
||
data: {
|
||
avatarUrl: '/images/default-avatar.png',
|
||
nickname: ''
|
||
},
|
||
onChooseAvatar(e) {
|
||
const { avatarUrl } = e.detail
|
||
// avatarUrl 是临时路径,需上传到自己服务器
|
||
this.setData({ avatarUrl })
|
||
this.uploadAvatar(avatarUrl)
|
||
},
|
||
onNicknameChange(e) {
|
||
this.setData({ nickname: e.detail.value })
|
||
},
|
||
async uploadAvatar(tempPath) {
|
||
wx.uploadFile({
|
||
url: 'https://your-server.com/api/upload/avatar',
|
||
filePath: tempPath,
|
||
name: 'file',
|
||
success(res) {
|
||
const data = JSON.parse(res.data)
|
||
// 保存头像 URL
|
||
}
|
||
})
|
||
}
|
||
})
|
||
```
|
||
|
||
## 授权管理
|
||
|
||
```javascript
|
||
// 查看当前授权状态
|
||
wx.getSetting({
|
||
success(res) {
|
||
res.authSetting['scope.userLocation'] // true/false/undefined
|
||
res.authSetting['scope.writePhotosAlbum']
|
||
// undefined = 未请求过,true = 已授权,false = 已拒绝
|
||
}
|
||
})
|
||
|
||
// 提前请求授权
|
||
wx.authorize({
|
||
scope: 'scope.userLocation',
|
||
success() { /* 授权成功 */ },
|
||
fail() { /* 用户拒绝 */ }
|
||
})
|
||
|
||
// 打开设置页(用户之前拒绝后,引导重新授权)
|
||
wx.openSetting({
|
||
success(res) {
|
||
res.authSetting // 最新的授权状态
|
||
}
|
||
})
|
||
```
|
||
|
||
### 常用 scope
|
||
|
||
| scope | 说明 | 对应 API |
|
||
|-------|------|----------|
|
||
| scope.userLocation | 精确地理位置 | wx.getLocation |
|
||
| scope.userFuzzyLocation | 模糊地理位置 | wx.getFuzzyLocation |
|
||
| scope.record | 麦克风 | wx.startRecord |
|
||
| scope.camera | 摄像头 | camera 组件 |
|
||
| scope.writePhotosAlbum | 保存到相册 | wx.saveImageToPhotosAlbum |
|
||
| scope.bluetooth | 蓝牙 | wx.openBluetoothAdapter |
|
||
| scope.addPhoneContact | 添加到联系人 | wx.addPhoneContact |
|
||
| scope.addPhoneCalendar | 添加到日历 | wx.addPhoneCalendar |
|
||
| scope.werun | 微信运动步数 | wx.getWeRunData |
|
||
| scope.userInfo | 已废弃 | - |
|
||
|
||
## 安全注意事项
|
||
|
||
1. **session_key 绝不能下发到前端**
|
||
2. **code 只能使用一次**,且有效期很短(约 5 分钟)
|
||
3. **不要在前端存储 openid**,通过 token 在后端关联
|
||
4. **JWT token 设置合理过期时间**(建议 7 天,配合 refresh token)
|
||
5. **HTTPS 是强制要求**,所有网络请求必须 HTTPS
|
||
6. **敏感数据解密**要在服务端进行,不要在前端解密
|
||
7. **access_token 要在服务端缓存**,不要每次请求都重新获取
|
||
8. **unionid 需要绑定开放平台**才能获取,用于跨应用用户关联
|
||
|
||
## 常见问题
|
||
|
||
### Q: wx.login 的 code 可以多次使用吗?
|
||
A: 不可以,每个 code 只能使用一次,且有效期约 5 分钟。
|
||
|
||
### Q: session_key 什么时候会过期?
|
||
A: 微信不会通知过期时间,需要通过 `wx.checkSession` 检查。用户越频繁使用小程序,session_key 有效期越长。
|
||
|
||
### Q: 如何获取 unionid?
|
||
A: 需要在微信开放平台绑定小程序。绑定后,code2Session 会返回 unionid。
|
||
|
||
### Q: getUserProfile 废弃后怎么获取用户信息?
|
||
A: 使用头像昵称填写能力(`<button open-type="chooseAvatar">` + `<input type="nickname">`),让用户主动填写。
|
||
|
||
## 在线查询
|
||
|
||
- 登录流程:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
|
||
- 手机号快速验证:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
|
||
- 用户信息:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html
|
||
- 授权:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/authorize.html
|