You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

633 lines
19 KiB

1 month ago
# 前端对接指南:钉钉扫码登录与免登
> 本文档面向前端开发者,详细讲解如何对接 CRM 后端的认证接口,实现钉钉扫码登录和免登(OAuth 重定向)。
---
## 1. 整体架构
```
┌─────────────────────────────────┐
│ 钉钉开放平台 OAuth2 │
└──────────┬───────────┬────────┘
扫码回调 authCode │ │ 重定向回带 authCode
│ │
┌──────────────┐ │ │ ┌──────────────┐
│ 扫码登录页面 │ │ │ │ 免登入口页面 │
│ (展示二维码) │ │ │ │ (检测未登录 │
└──────┬───────┘ │ │ │ → 重定向) │
│ authCode │ │ └──────┬───────┘
│ │ │ │ authCode
▼ │ │ ▼
┌─────────────────────────────────────────────────────┐
│ POST /api/auth/login/dingtalk │
│ { "authCode": "xxx" } │
└──────────────────────┬──────────────────────────────┘
┌───────────────▼───────────────┐
│ 后端:authCode → userAccessToken │
│ → 钉钉用户信息 → 匹配/注册本地用户 │
│ → 签发 JWT + Redis 登录态 │
└───────────────┬───────────────┘
{ token: "xxx", userInfo: { id, username, ... } }
```
**核心事实**:扫码登录和免登在后端走**同一个端点、同一套逻辑**。区别仅在前端获取 `authCode` 的方式。详见 [ADR-0009](../docs/adr/0009-sso-reuses-scan-login-endpoint.md)。
---
## 2. 后端 API 清单
### 2.1 钉钉登录(扫码 / 免登通用)
```
POST /api/auth/login/dingtalk
Content-Type: application/x-www-form-urlencoded
```
**请求参数**(form-data):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| authCode | string | 是 | 钉钉扫码回调或 OAuth 重定向返回的授权码,有效期 5 分钟,只能用一次 |
**成功响应**(HTTP 200):
```json
{
"code": 0,
"success": true,
"message": "success",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.xxxx",
"userInfo": {
"id": 1001,
"username": "张三",
"mobile": "13800000001",
"email": "zs@example.com",
"avatar": "https://cdn/avatar.png",
"deptId": 10,
"deptName": "销售部",
"menus": []
}
}
}
```
| 字段 | 类型 | 说明 |
|---|---|---|
| token | string | JWT token,后续所有请求放入 `Authorization: Bearer {token}` |
| userInfo.id | number | 用户 ID |
| userInfo.username | string | 用户显示名 |
| userInfo.mobile | string | 手机号 |
| userInfo.email | string | 邮箱 |
| userInfo.avatar | string | 头像 URL |
| userInfo.deptId | number \| null | 归属部门 ID |
| userInfo.deptName | string \| null | 归属部门名称 |
| userInfo.menus | array | 当前用户菜单树(首次登录时填充) |
### 2.2 获取当前用户信息
```
GET /api/auth/me
Authorization: Bearer {token}
```
**成功响应**:同上 `userInfo` 结构。
### 2.3 注销
```
POST /api/auth/logout
Authorization: Bearer {token}
```
注销后 token 立即失效(Redis 删除),无需传请求体。
---
## 3. Token 机制
### 3.1 存储
登录成功后,将 `data.token` 存入前端状态管理(如 localStorage / Pinia / Zustand)。
### 3.2 携带
后续所有**需要鉴权**的请求,在请求头中携带:
```
Authorization: Bearer {token}
```
### 3.3 有效期与续期
- 有效期:**7 天**(后端配置 `crm.auth.jwt.ttl-days: 7`
- 续期:剩余有效期不足一半时,后端**自动续满**(滑动过期),前端无感知
- 强制下线:管理员删除 Redis 中的登录态后,token 立即失效
### 3.4 多端登录
同一用户可在多个设备同时登录,每个登录态有独立的 `jti`(JWT ID),互不影响。注销一个设备不影响其他设备的登录态。
---
## 4. 错误处理
### 4.1 统一响应格式
所有接口返回 HTTP 200(系统级异常除外),通过 `code``success` 判断业务成败:
```json
{
"code": 61002,
"success": false,
"message": "钉钉授权失败,请重新扫码",
"data": null
}
```
### 4.2 登录相关错误码
| HTTP 状态码 | code | 含义 | 前端处理建议 |
|---|---|---|---|
| 200 | 0 | 成功 | 存 token,跳首页 |
| 200 | 40001 | 参数缺失(authCode 为空) | 提示用户重新操作 |
| 200 | 61002 | 钉钉授权失败(authCode 无效/过期) | 提示"授权失败,请重新扫码",引导重试 |
| 200 | 61003 | 账号已被禁用 | 提示"账号已被禁用,请联系管理员" |
| 401 | 40103 | 未登录或登录已过期 | 清除本地 token,跳登录页 |
| 403 | 40301 | 权限不足 | 提示"无权限访问" |
| 500 | 50001 | 系统内部错误 | 提示"系统异常,请稍后重试" |
### 4.3 401 拦截器(推荐实现)
在 HTTP 请求拦截器中统一处理 401:
```javascript
// axios 示例
axios.interceptors.response.use(
(response) => response.data, // 直接返回 data 部分
(error) => {
if (error.response?.status === 401) {
// 登录态失效,清除 token,跳登录页
localStorage.removeItem('token')
router.push('/login')
}
return Promise.reject(error)
}
)
```
> **注意**:401 和 403 由 Spring Security 过滤器链直接返回(进不了 Controller),HTTP 状态码分别为 401 和 403,响应体是 JSON 格式的 `Result` 对象。其他业务错误 HTTP 状态码为 200。
---
## 5. 扫码登录实现
### 5.1 钉钉扫码二维码
钉钉提供了两种扫码登录前端方案:
#### 方案 A:钉钉提供的扫码组件(推荐)
在登录页面引入钉钉的扫码登录 JS,渲染二维码:
```html
<!-- 第一步:引入钉钉扫码登录 JS -->
<script src="https://login.dingtalk.com/login/qrcode.htm?appid={clientId}&goto={redirectUri}&hl=zh-CN"></script>
```
或使用钉钉新版 SDK:
```javascript
// 第二步:初始化扫码组件
window.DTFrameLogin(
{
id: 'ding-login-container', // 容器元素 ID
width: '300',
height: '300',
},
{
redirect_uri: encodeURIComponent('http://localhost:5174/auth/callback'),
client_id: 'dingikqv3al2koirnku7', // 钉钉应用 Client ID
scope: 'openid',
prompt: 'consent',
response_type: 'code',
},
(loginResult) => {
const { redirectUrl, authCode } = loginResult
// 拿到 authCode,调后端登录接口
callBackendLogin(authCode)
},
(errorMsg) => {
console.error('扫码失败:', errorMsg)
}
)
```
#### 方案 B:直接跳转钉钉登录页
```javascript
// 如果不想在页面内嵌二维码,可以直接跳转
const clientId = 'dingikqv3al2koirnku7'
const redirectUri = encodeURIComponent('http://localhost:5174/auth/callback')
window.location.href = `https://login.dingtalk.com/oauth2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&prompt=consent`
```
### 5.2 回调页面
用户扫码确认后,钉钉重定向回回调地址,URL 上会带 `authCode` 参数:
```
http://localhost:5174/auth/callback?authCode=xxxxx
```
在回调页面提取 `authCode` 并调后端:
```javascript
// auth-callback.vue / auth-callback.tsx
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('authCode')
if (authCode) {
// 调后端登录接口(form-data)
const res = await fetch('/api/auth/login/dingtalk', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ authCode })
})
const result = await res.json()
if (result.code === 0) {
// 登录成功,存 token
localStorage.setItem('token', result.data.token)
// 存用户信息
localStorage.setItem('userInfo', JSON.stringify(result.data.userInfo))
// 跳首页
router.push('/')
} else {
// 登录失败
alert(result.message) // 如"钉钉授权失败,请重新扫码"
router.push('/login')
}
} else {
// 无 authCode,可能用户取消
router.push('/login')
}
```
---
## 6. 免登实现(OAuth 重定向)
### 6.1 场景说明
用户在浏览器中已登录钉钉后访问 CRM,前端检测到未登录态(本地无 token 或 token 已过期),自动重定向到钉钉 OAuth 授权页。钉钉检测到已登录态后,**自动重定向回** CRM 并携带 `authCode`,前端用 authCode 调同一个登录接口。
### 6.2 实现步骤
```javascript
// router 守卫 / axios 拦截器中
function redirectToDingTalkLogin() {
const clientId = 'dingikqv3al2koirnku7' // 钉钉应用 Client ID
const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback')
window.location.href =
`https://login.dingtalk.com/oauth2/auth` +
`?client_id=${clientId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=openid` +
`&prompt=consent`
}
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
// 未登录,重定向到钉钉 OAuth
redirectToDingTalkLogin()
} else if (to.path === '/auth/callback') {
// 回调页面,放行让回调页面处理 authCode
next()
} else {
next()
}
})
```
### 6.3 回调页面(与扫码登录共用)
免登和扫码登录的回调页面**完全相同**——都是提取 URL 上的 `authCode`,调 `POST /api/auth/login/dingtalk`,详见 [5.2 回调页面](#52-回调页面)。
### 6.4 扫码 vs 免登的前端差异
| | 扫码登录 | 免登 |
|---|---|---|
| **触发时机** | 用户在登录页点击"扫码登录" | 路由守卫检测到未登录,自动触发 |
| **前端行为** | 页面内嵌钉钉二维码组件 / 跳转钉钉登录页 | 直接跳转钉钉 OAuth 授权页 |
| **钉钉行为** | 显示二维码等待用户扫 | 检测到已登录态,**立即重定向**回来 |
| **后端处理** | 完全一致 | 完全一致 |
| **回调页面** | 同一个 `/auth/callback` | 同一个 `/auth/callback` |
---
## 7. 钉钉开放平台配置
### 7.1 应用配置
在[钉钉开放平台](https://open.dingtalk.com)创建或打开已有应用,记录以下信息:
| 配置项 | 值(示例) | 说明 |
|---|---|---|
| Client ID (AppKey) | `dingikqv3al2koirnku7` | 前端构造 OAuth URL 用 |
| Client Secret (AppSecret) | (后端配置,前端不接触) | 后端换 token 用,已配置在后端 |
### 7.2 回调域名
在应用的"安全设置"中,添加**登录回调域名**:
- 开发环境:`http://localhost:5174`
- 生产环境:`https://your-crm-domain.com`
> **注意**:回调域名需要与前端 `redirect_uri` 参数的域名完全一致,否则钉钉会拒绝。
### 7.3 权限申请
在应用的"权限管理"中,确保已开通以下权限:
| 权限 | 权限标识 | 用途 |
|---|---|---|
| 个人手机号信息 | `Contact.User.mobile` | 获取用户手机号(跨平台身份合一的关键字段) |
| 通讯录个人信息读权限 | `Contact.User.Read` | 获取用户基本信息(姓名/头像等) |
### 7.4 服务器出口 IP
在应用的"安全设置"中,添加**服务器出口 IP**(后端服务器 IP):
- 开发环境:`127.0.0.1` 或本机公网 IP
- 生产环境:后端服务器公网 IP
> 未配置 IP 白名单时,后端调钉钉 API 会被拒绝。
---
## 8. 完整前端代码示例
### 8.1 项目结构建议
```
src/
├── api/
│ └── auth.ts # 认证相关 API 封装
├── router/
│ └── index.ts # 路由守卫
├── stores/
│ └── auth.ts # 登录态管理
└── views/
├── Login.vue # 登录页(扫码二维码)
└── AuthCallback.vue # OAuth 回调页
```
### 8.2 API 封装(`src/api/auth.ts`)
```typescript
const BASE_URL = '/api/auth'
// 钉钉登录(扫码 / 免登通用)
export async function dingTalkLogin(authCode: string) {
const res = await fetch(`${BASE_URL}/login/dingtalk`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ authCode }),
})
return res.json()
}
// 获取当前用户信息
export async function getCurrentUser() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE_URL}/me`, {
headers: {
'Authorization': `Bearer ${token}`,
},
})
return res.json()
}
// 注销
export async function logout() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE_URL}/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
})
return res.json()
}
```
### 8.3 路由守卫(`src/router/index.ts`)
```typescript
import { redirectToDingTalkLogin } from '@/utils/dingtalk'
const routes = [
{ path: '/login', component: LoginView, meta: { public: true } },
{ path: '/auth/callback', component: AuthCallbackView, meta: { public: true } },
{ path: '/', component: HomeView, meta: { requiresAuth: true } },
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
// 未登录 → 免登:重定向到钉钉 OAuth
redirectToDingTalkLogin()
return false
}
if (to.meta.public) {
return true
}
})
```
### 8.4 钉钉工具函数(`src/utils/dingtalk.ts`)
```typescript
// 钉钉应用 Client ID(公开值,非密钥)
const DINGTALK_CLIENT_ID = 'dingikqv3al2koirnku7'
// 重定向到钉钉 OAuth 授权页(免登入口)
export function redirectToDingTalkLogin() {
const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback')
window.location.href =
`https://login.dingtalk.com/oauth2/auth` +
`?client_id=${DINGTALK_CLIENT_ID}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=openid` +
`&prompt=consent`
}
// 获取 URL 上的 authCode 参数
export function getAuthCodeFromUrl(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('authCode') || params.get('code')
}
```
### 8.5 回调页面(`src/views/AuthCallback.vue`)
```vue
<template>
<div class="auth-callback">
<span v-if="loading">正在登录...</span>
<span v-if="error">{{ error }}</span>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { dingTalkLogin } from '@/api/auth'
import { getAuthCodeFromUrl } from '@/utils/dingtalk'
const router = useRouter()
const loading = ref(true)
const error = ref('')
onMounted(async () => {
const authCode = getAuthCodeFromUrl()
if (!authCode) {
error.value = '未收到钉钉授权码'
router.push('/login')
return
}
const result = await dingTalkLogin(authCode)
if (result.code === 0) {
// 登录成功
localStorage.setItem('token', result.data.token)
localStorage.setItem('userInfo', JSON.stringify(result.data.userInfo))
router.push('/')
} else {
// 登录失败
error.value = result.message
setTimeout(() => router.push('/login'), 2000)
}
loading.value = false
})
</script>
```
### 8.6 Axios 拦截器(统一携带 token + 401 处理)
```typescript
import axios from 'axios'
const http = axios.create({ baseURL: '/api' })
// 请求拦截:自动携带 token
http.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截:统一处理 401
http.interceptors.response.use(
(response) => {
// 业务错误:code !== 0
if (response.data.code !== 0) {
return Promise.reject(response.data)
}
return response.data.data // 直接返回 data 部分
},
(error) => {
if (error.response?.status === 401) {
// 登录态失效,清除 token,重定向到钉钉免登
localStorage.removeItem('token')
redirectToDingTalkLogin()
}
return Promise.reject(error)
}
)
export default http
```
---
## 9. 开发环境信息
| 配置项 | 开发环境值 |
|---|---|
| 后端地址 | `http://localhost:8081` |
| 前端地址(建议) | `http://localhost:5174` |
| 钉钉 Client ID | `dingikqv3al2koirnku7` |
| API 文档(Knife4j) | `http://localhost:8081/doc.html` |
### 前端代理配置
开发环境需要配置 Vite 代理,将 `/api` 请求代理到后端:
```javascript
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
},
},
},
})
```
---
## 10. 常见问题
### Q: authCode 的有效期?
5 分钟,且只能使用一次。如果用户在回调页面停留太久,authCode 会过期,后端返回 `61002`,需引导用户重新扫码或重新触发免登。
### Q: 首次登录的用户会发生什么?
后端自动注册新用户(用钉钉返回的昵称、手机号、头像),并绑定钉钉身份。用户无需手动注册。后续同一钉钉账号再次登录,直接匹配已有身份,无需重新注册。
### Q: 用户换了手机号怎么办?
后端优先按钉钉 `unionId` 匹配身份。如果 unionId 已绑定,直接取已有用户,不会因为手机号变更而注册新账号。只有当 unionId 未绑定时,才按手机号匹配。
### Q: 登录后用户信息存在哪里?
`POST /api/auth/login/dingtalk` 的响应中直接包含 `userInfo`,前端登录成功后即可拿到用户信息并展示。如需刷新用户信息(如修改了部门),调用 `GET /api/auth/me`
### Q: 免登和扫码登录可以同时支持吗?
可以,且推荐同时支持。两者的回调页面和后端接口完全相同,只是触发方式不同:
- 扫码登录:用户在登录页看到二维码并扫码
- 免登:路由守卫检测未登录,自动跳转钉钉 OAuth(已登录则秒回)
前端可以根据路由参数区分用户是从哪种流程回来的。
### Q: 如何区分"未登录需免登"和"已登录正常访问"?
在路由守卫中检查本地是否有有效 token。如果有 token,正常放行;如果没有 token,重定向到钉钉 OAuth。token 失效(401)时由 axios 拦截器自动清除并重定向。