701 lines
17 KiB
Plaintext
701 lines
17 KiB
Plaintext
# 钉钉登录配置完整文档
|
||
|
||
本文档详细说明了项目中钉钉登录功能的完整配置,可用于在其他项目中复制该功能。
|
||
|
||
---
|
||
|
||
## 一、概述
|
||
|
||
本项目实现了完整的钉钉扫码登录功能,支持:
|
||
- ✅ 钉钉扫码登录(Web端)
|
||
- ✅ 钉钉内免密登录
|
||
- ✅ 多组织切换
|
||
- ✅ 自动创建用户
|
||
- ✅ 用户信息同步
|
||
|
||
---
|
||
|
||
## 二、钉钉应用配置
|
||
|
||
### 2.1 钉钉开放平台配置
|
||
|
||
需要在钉钉开放平台创建应用,并获取以下信息:
|
||
|
||
#### 应用类型
|
||
- **H5微应用** 或 **第三方企业应用**
|
||
|
||
#### 必需的配置参数
|
||
```javascript
|
||
{
|
||
clientId: 'dingxxxxxxxxxx', // 应用的 AppKey/ClientId
|
||
clientSecret: 'xxxxxxxxxxxxxx', // 应用的 AppSecret/ClientSecret
|
||
corpId: 'dingxxxxxxxxxx' // 企业 CorpId(仅内部免密登录需要)
|
||
}
|
||
```
|
||
|
||
#### 权限配置
|
||
需要申请以下权限:
|
||
- `Contact.User.Read` - 通讯录只读权限
|
||
- `openid` - 获取用户OpenID
|
||
|
||
#### 回调域名配置
|
||
在钉钉开放平台配置以下回调地址:
|
||
```
|
||
http://your-domain.com/dingtalk-callback.html
|
||
http://your-domain.com/dingtalk.html
|
||
```
|
||
|
||
### 2.2 项目中的配置
|
||
|
||
#### 后端配置文件:`server/routes/dingtalk.js`
|
||
|
||
```javascript
|
||
// 钉钉应用配置
|
||
const DINGTALK_CONFIG = {
|
||
test: {
|
||
clientId: 'dingmnpddoxnelhouyiq',
|
||
clientSecret: '9efbE4X-Nmk5rl21FZ3iGa6gQtQWXk4xMquy6Lmt2adN3OJQMBrCN_40DZg-6xPK',
|
||
},
|
||
jianshang: {
|
||
clientId: 'ding1bs1gxwrcfu16x0f',
|
||
clientSecret: 'qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY',
|
||
}
|
||
};
|
||
```
|
||
|
||
> ⚠️ **安全提示**:生产环境应将这些配置移至环境变量 `.env` 文件中
|
||
|
||
---
|
||
|
||
## 三、数据库配置
|
||
|
||
### 3.1 用户模型扩展
|
||
|
||
在 `server/models/User.js` 中添加钉钉登录相关字段:
|
||
|
||
```javascript
|
||
const userSchema = new mongoose.Schema({
|
||
// ... 其他字段
|
||
|
||
// 钉钉登录相关字段
|
||
dingtalk_userid: {
|
||
type: String,
|
||
sparse: true, // 允许多个null值,但不允许重复的非null值
|
||
unique: true
|
||
},
|
||
loginType: {
|
||
type: String,
|
||
enum: ['normal', 'dingtalk'],
|
||
default: 'normal'
|
||
},
|
||
phone: {
|
||
type: String,
|
||
sparse: true,
|
||
unique: true
|
||
},
|
||
realName: {
|
||
type: String,
|
||
trim: true
|
||
},
|
||
isEmailVerified: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 四、后端路由配置
|
||
|
||
### 4.1 主服务器配置
|
||
|
||
在 `server/server.js` 中引入钉钉路由:
|
||
|
||
```javascript
|
||
// 导入钉钉登录路由
|
||
const dingtalkRoutes = require('./routes/dingtalk');
|
||
|
||
// 注册路由
|
||
app.use('/api/dingtalk', dingtalkRoutes);
|
||
```
|
||
|
||
### 4.2 钉钉登录路由
|
||
|
||
创建文件 `server/routes/dingtalk.js`,包含以下接口:
|
||
|
||
#### 接口1:OAuth2验证接口
|
||
```javascript
|
||
GET /api/dingtalk/oauth2
|
||
参数:
|
||
- code: 授权码
|
||
- company: 公司标识(默认mistong)
|
||
```
|
||
|
||
#### 接口2:钉钉登录接口
|
||
```javascript
|
||
POST /api/dingtalk/login
|
||
请求体:
|
||
{
|
||
"code": "授权码",
|
||
"state": "公司标识"
|
||
}
|
||
响应:
|
||
{
|
||
"success": true,
|
||
"message": "登录成功",
|
||
"user": {...},
|
||
"token": "JWT令牌"
|
||
}
|
||
```
|
||
|
||
#### 接口3:钉钉免密登录接口(用于钉钉内部)
|
||
```javascript
|
||
POST /api/dingtalk/autologin
|
||
请求体:
|
||
{
|
||
"code": "免登授权码",
|
||
"state": "公司标识"
|
||
}
|
||
```
|
||
|
||
### 4.3 核心函数实现
|
||
|
||
```javascript
|
||
// 获取用户访问令牌
|
||
async function getUserAccessToken(code, company) {
|
||
const config = DINGTALK_CONFIG[company];
|
||
const url = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken';
|
||
|
||
const params = {
|
||
clientId: config.clientId,
|
||
clientSecret: config.clientSecret,
|
||
code: code,
|
||
grantType: 'authorization_code'
|
||
};
|
||
|
||
const response = await axios.post(url, params, {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
return response.data;
|
||
}
|
||
|
||
// 获取应用访问令牌
|
||
async function getAppAccessToken(company) {
|
||
const config = DINGTALK_CONFIG[company];
|
||
const url = 'https://oapi.dingtalk.com/gettoken';
|
||
|
||
const response = await axios.get(url, {
|
||
params: {
|
||
appkey: config.clientId,
|
||
appsecret: config.clientSecret
|
||
}
|
||
});
|
||
|
||
return response.data.access_token;
|
||
}
|
||
|
||
// 根据token获取用户unionId
|
||
async function getUnionId(token) {
|
||
const url = 'https://api.dingtalk.com/v1.0/contact/users/me';
|
||
|
||
const response = await axios.get(url, {
|
||
headers: { 'x-acs-dingtalk-access-token': token }
|
||
});
|
||
|
||
return response.data.unionId;
|
||
}
|
||
|
||
// 根据unionId获取userId
|
||
async function getUserIdByUnionId(unionId, appToken) {
|
||
const url = `https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appToken}`;
|
||
|
||
const response = await axios.post(url, { unionid: unionId });
|
||
|
||
return response.data.result.userid;
|
||
}
|
||
|
||
// 根据userId获取用户信息
|
||
async function getUserInfo(userId, appToken) {
|
||
const url = `https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appToken}`;
|
||
|
||
const response = await axios.post(url, { userid: userId });
|
||
|
||
return response.data.result;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、前端配置
|
||
|
||
### 5.1 登录页面配置
|
||
|
||
#### 文件:`public/login.html`
|
||
|
||
**1. 引入钉钉JS-SDK**
|
||
```html
|
||
<!-- 不需要额外引入,使用iframe方式 -->
|
||
```
|
||
|
||
**2. 钉钉组织配置**
|
||
```javascript
|
||
// 钉钉组织配置
|
||
const dingTalkOrgList = [
|
||
{
|
||
id: 'jianshang',
|
||
name: '简尚',
|
||
code: 'jianshang',
|
||
app_key: 'ding1bs1gxwrcfu16x0f',
|
||
logo: null,
|
||
desc: ''
|
||
}
|
||
];
|
||
```
|
||
|
||
**3. 钉钉登录按钮UI**
|
||
```html
|
||
<div class="dingtalk-login-section">
|
||
<div class="dingtalk-login-container" id="dingtalkContainer">
|
||
<div class="dingtalk-main-btn" id="dingtalkMainBtn">
|
||
<svg style="width: 28px; height: 28px" viewBox="0 0 1024 1024">
|
||
<path d="..." fill="#3296FA"></path>
|
||
</svg>
|
||
<span style="font-size: 18px">钉钉登录</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**4. 钉钉登录核心JavaScript**
|
||
```javascript
|
||
function openDTLogin(org) {
|
||
const param = {
|
||
redirect_uri: encodeURIComponent(location.origin + '/dingtalk-callback.html'),
|
||
client_id: org.app_key,
|
||
scope: 'openid',
|
||
response_type: 'code',
|
||
state: org.code,
|
||
prompt: 'consent'
|
||
};
|
||
|
||
const redirectUrl = 'https://login.dingtalk.com/oauth2/auth?' +
|
||
Object.keys(param).map(key => key + '=' + param[key]).join('&');
|
||
|
||
// 在iframe中打开钉钉登录页面
|
||
showDingtalkIframe(redirectUrl);
|
||
}
|
||
|
||
// 显示钉钉登录iframe
|
||
function showDingtalkIframe(url) {
|
||
const overlay = document.getElementById('dingtalkIframeOverlay');
|
||
const iframe = document.getElementById('dingtalkIframe');
|
||
|
||
iframe.src = url;
|
||
overlay.style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
// 监听iframe消息
|
||
window.addEventListener('message', function (event) {
|
||
if (event.data && event.data.type === 'dingtalk_login_success') {
|
||
closeDingtalkLogin();
|
||
// 保存用户信息
|
||
localStorage.setItem('isLoggedIn', 'true');
|
||
localStorage.setItem('username', event.data.userInfo.username);
|
||
localStorage.setItem('userRole', event.data.userInfo.role);
|
||
// 跳转到首页
|
||
window.location.href = '/';
|
||
}
|
||
});
|
||
```
|
||
|
||
### 5.2 钉钉回调页面
|
||
|
||
#### 文件:`public/dingtalk-callback.html`
|
||
|
||
这是钉钉OAuth2回调页面,处理授权码并完成登录:
|
||
|
||
```javascript
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const code = urlParams.get('code');
|
||
const state = urlParams.get('state') || 'mistong';
|
||
|
||
if (!code) {
|
||
showError('授权失败:缺少授权码');
|
||
return;
|
||
}
|
||
|
||
// 调用登录接口
|
||
login(code, state);
|
||
});
|
||
|
||
async function login(code, company) {
|
||
try {
|
||
const response = await fetch('/api/dingtalk/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, state: company }),
|
||
credentials: 'include'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.success) {
|
||
// 保存登录状态
|
||
localStorage.setItem('isLoggedIn', 'true');
|
||
localStorage.setItem('username', data.user.username);
|
||
localStorage.setItem('token', data.token);
|
||
|
||
// 通知父窗口登录成功
|
||
if (window.parent !== window) {
|
||
window.parent.postMessage({
|
||
type: 'dingtalk_login_success',
|
||
userInfo: data.user,
|
||
token: data.token
|
||
}, '*');
|
||
} else {
|
||
window.location.href = '/';
|
||
}
|
||
} else {
|
||
throw new Error(data.message || '登录失败');
|
||
}
|
||
} catch (error) {
|
||
showError(error.message);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.3 钉钉内免密登录页面
|
||
|
||
#### 文件:`public/dingtalk.html`
|
||
|
||
用于在钉钉客户端内部实现免密登录:
|
||
|
||
```html
|
||
<script src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.1.0/dingtalk.open.js"></script>
|
||
|
||
<script>
|
||
const isDingTalk = () => {
|
||
const ua = navigator.userAgent;
|
||
return ua.includes('DingTalk');
|
||
};
|
||
|
||
if (isDingTalk()) {
|
||
// 在钉钉中使用免登授权码
|
||
dd.ready(function () {
|
||
dd.runtime.permission.requestAuthCode({
|
||
corpId: "dingd590aa10d5b1efad35c2f4657eb6378f", // 企业id
|
||
onSuccess: function (info) {
|
||
const code = info.code;
|
||
window.open("/dingtalk.html?code=" + code);
|
||
}
|
||
});
|
||
});
|
||
} else {
|
||
// 在外部浏览器中使用授权码登录
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const code = urlParams.get('code');
|
||
const state = urlParams.get('state') || 'jianshang';
|
||
|
||
login(code, state);
|
||
}
|
||
|
||
async function login(code, company) {
|
||
const response = await fetch('/api/dingtalk/autologin', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, state: company }),
|
||
credentials: 'include'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
localStorage.setItem('isLoggedIn', 'true');
|
||
localStorage.setItem('token', data.token);
|
||
window.location.href = '/';
|
||
}
|
||
}
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
## 六、环境变量配置
|
||
|
||
在 `.env` 文件中添加以下配置:
|
||
|
||
```env
|
||
# JWT密钥(必需)
|
||
JWT_SECRET=your_jwt_secret_key_here
|
||
|
||
# 钉钉应用配置(可选,也可以在代码中配置)
|
||
DINGTALK_TEST_CLIENT_ID=dingmnpddoxnelhouyiq
|
||
DINGTALK_TEST_CLIENT_SECRET=your_secret_here
|
||
|
||
DINGTALK_JIANSHANG_CLIENT_ID=ding1bs1gxwrcfu16x0f
|
||
DINGTALK_JIANSHANG_CLIENT_SECRET=your_secret_here
|
||
DINGTALK_JIANSHANG_CORP_ID=dingd590aa10d5b1efad35c2f4657eb6378f
|
||
|
||
# 会话密钥
|
||
SESSION_SECRET=your_session_secret_key_here
|
||
|
||
# Node环境
|
||
NODE_ENV=production
|
||
```
|
||
|
||
---
|
||
|
||
## 七、依赖包
|
||
|
||
需要安装以下npm包:
|
||
|
||
```json
|
||
{
|
||
"dependencies": {
|
||
"express": "^4.21.2",
|
||
"axios": "^1.8.4",
|
||
"jsonwebtoken": "^9.0.2",
|
||
"bcrypt": "^6.0.0",
|
||
"mongoose": "^6.13.8",
|
||
"cookie-parser": "^1.4.7",
|
||
"express-session": "^1.18.1",
|
||
"dotenv": "^16.4.7"
|
||
}
|
||
}
|
||
```
|
||
|
||
安装命令:
|
||
```bash
|
||
npm install express axios jsonwebtoken bcrypt mongoose cookie-parser express-session dotenv
|
||
```
|
||
|
||
---
|
||
|
||
## 八、完整登录流程
|
||
|
||
### 8.1 Web扫码登录流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User as 用户
|
||
participant Web as Web页面
|
||
participant DT as 钉钉服务器
|
||
participant Backend as 后端服务器
|
||
participant DB as 数据库
|
||
|
||
User->>Web: 点击钉钉登录按钮
|
||
Web->>DT: 跳转到钉钉授权页面
|
||
User->>DT: 扫码授权
|
||
DT->>Web: 返回授权码(code)
|
||
Web->>Backend: POST /api/dingtalk/login {code, state}
|
||
Backend->>DT: 用授权码换取用户token
|
||
DT->>Backend: 返回access_token
|
||
Backend->>DT: 用token获取unionId
|
||
DT->>Backend: 返回unionId
|
||
Backend->>DT: 用unionId获取userId
|
||
DT->>Backend: 返回userId
|
||
Backend->>DT: 用userId获取用户详细信息
|
||
DT->>Backend: 返回用户信息
|
||
Backend->>DB: 查找或创建用户
|
||
DB->>Backend: 返回用户数据
|
||
Backend->>Web: 返回JWT token和用户信息
|
||
Web->>User: 登录成功,跳转首页
|
||
```
|
||
|
||
### 8.2 钉钉内免密登录流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User as 用户
|
||
participant DTClient as 钉钉客户端
|
||
participant Web as Web页面
|
||
participant Backend as 后端服务器
|
||
participant DB as 数据库
|
||
|
||
User->>DTClient: 在钉钉中打开应用
|
||
DTClient->>Web: 加载页面
|
||
Web->>DTClient: 调用dd.runtime.permission.requestAuthCode
|
||
DTClient->>Web: 返回免登授权码
|
||
Web->>Backend: POST /api/dingtalk/autologin {code, state}
|
||
Backend->>DTClient: 用免登码获取userId
|
||
Backend->>DTClient: 获取用户详细信息
|
||
Backend->>DB: 查找或创建用户
|
||
Backend->>Web: 返回JWT token
|
||
Web->>User: 自动登录成功
|
||
```
|
||
|
||
---
|
||
|
||
## 九、关键代码说明
|
||
|
||
### 9.1 用户创建逻辑
|
||
|
||
当钉钉用户首次登录时,系统会自动创建账户:
|
||
|
||
```javascript
|
||
// 首先通过钉钉userid查找用户
|
||
let user = await User.findOne({ dingtalk_userid: userId });
|
||
|
||
if (!user) {
|
||
// 创建新用户
|
||
const randomPassword = generateRandomPassword(10);
|
||
const hashedPassword = await bcrypt.hash(randomPassword, 10);
|
||
|
||
user = new User({
|
||
username: userInfo.job_number || userInfo.name,
|
||
email: userInfo.email || generateRandomEmail(),
|
||
password: hashedPassword,
|
||
realName: userInfo.name,
|
||
avatar: userInfo.avatar,
|
||
phone: userInfo.mobile || null,
|
||
role: 'user',
|
||
status: 'active',
|
||
isEmailVerified: true, // 钉钉用户默认已验证
|
||
loginType: 'dingtalk',
|
||
dingtalk_userid: userId
|
||
});
|
||
|
||
await user.save();
|
||
}
|
||
```
|
||
|
||
### 9.2 JWT Token生成
|
||
|
||
```javascript
|
||
// 生成JWT令牌
|
||
const token = jwt.sign(
|
||
{
|
||
userId: user._id,
|
||
username: user.username,
|
||
role: user.role
|
||
},
|
||
process.env.JWT_SECRET || 'your-secret-key',
|
||
{ expiresIn: '7d' }
|
||
);
|
||
|
||
// 设置cookie
|
||
res.cookie('token', token, {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 十、安全建议
|
||
|
||
### 10.1 配置安全
|
||
- ✅ 将所有敏感配置(clientSecret等)移至环境变量
|
||
- ✅ 不要将 `.env` 文件提交到版本控制
|
||
- ✅ 使用强随机的JWT_SECRET
|
||
|
||
### 10.2 传输安全
|
||
- ✅ 生产环境使用HTTPS
|
||
- ✅ 启用Cookie的secure标志
|
||
- ✅ 使用httpOnly防止XSS攻击
|
||
|
||
### 10.3 数据安全
|
||
- ✅ 密码使用bcrypt加密
|
||
- ✅ Token设置合理的过期时间
|
||
- ✅ 定期清理过期的token
|
||
|
||
---
|
||
|
||
## 十一、常见问题
|
||
|
||
### Q1: 回调页面显示"授权失败:缺少授权码"
|
||
**原因**:钉钉回调地址配置错误或未在钉钉开放平台配置
|
||
**解决**:检查钉钉开放平台的回调地址配置
|
||
|
||
### Q2: 登录后提示"获取用户信息失败"
|
||
**原因**:应用权限不足
|
||
**解决**:在钉钉开放平台检查是否已申请通讯录读取权限
|
||
|
||
### Q3: 钉钉内免密登录失败
|
||
**原因**:corpId配置错误
|
||
**解决**:确认corpId是正确的企业ID
|
||
|
||
### Q4: Token验证失败
|
||
**原因**:JWT_SECRET配置不一致
|
||
**解决**:确保所有服务使用相同的JWT_SECRET
|
||
|
||
---
|
||
|
||
## 十二、测试清单
|
||
|
||
部署前请完成以下测试:
|
||
|
||
- [ ] Web端钉钉扫码登录
|
||
- [ ] 钉钉客户端内免密登录
|
||
- [ ] 新用户自动创建
|
||
- [ ] 已有用户登录
|
||
- [ ] Token有效性验证
|
||
- [ ] 登出功能
|
||
- [ ] 用户信息同步
|
||
- [ ] 多组织切换(如有)
|
||
|
||
---
|
||
|
||
## 十三、部署步骤
|
||
|
||
### 13.1 后端部署
|
||
1. 复制 `server/routes/dingtalk.js` 到新项目
|
||
2. 更新 `server/models/User.js` 添加钉钉相关字段
|
||
3. 在 `server/server.js` 中注册钉钉路由
|
||
4. 配置环境变量
|
||
|
||
### 13.2 前端部署
|
||
1. 复制 `public/login.html` 中的钉钉登录部分
|
||
2. 复制 `public/dingtalk-callback.html`
|
||
3. 复制 `public/dingtalk.html`
|
||
4. 更新钉钉组织配置
|
||
|
||
### 13.3 钉钉配置
|
||
1. 在钉钉开放平台创建应用
|
||
2. 配置回调地址
|
||
3. 申请必要权限
|
||
4. 获取clientId和clientSecret
|
||
|
||
### 13.4 测试验证
|
||
1. 启动服务器
|
||
2. 访问登录页面
|
||
3. 点击钉钉登录按钮
|
||
4. 扫码完成登录
|
||
5. 验证用户信息正确保存
|
||
|
||
---
|
||
|
||
## 十四、技术支持
|
||
|
||
### 参考文档
|
||
- [钉钉开放平台](https://open.dingtalk.com/)
|
||
- [钉钉OAuth2.0](https://open.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)
|
||
- [钉钉JS-API](https://open.dingtalk.com/document/orgapp/h5-development-overview)
|
||
|
||
### API文档
|
||
- 用户访问令牌:`https://api.dingtalk.com/v1.0/oauth2/userAccessToken`
|
||
- 应用访问令牌:`https://oapi.dingtalk.com/gettoken`
|
||
- 获取用户信息:`https://api.dingtalk.com/v1.0/contact/users/me`
|
||
- 根据unionId获取userId:`https://oapi.dingtalk.com/topapi/user/getbyunionid`
|
||
- 获取用户详情:`https://oapi.dingtalk.com/topapi/v2/user/get`
|
||
|
||
---
|
||
|
||
## 十五、版本信息
|
||
|
||
- **文档版本**:v1.0
|
||
- **更新日期**:2025-11-05
|
||
- **适用项目**:yedir (视频魔方AI)
|
||
- **Node.js版本**:≥14.0.0
|
||
- **Express版本**:^4.21.2
|
||
|
||
---
|
||
|
||
**注意**:本文档包含敏感配置信息示例,实际使用时请替换为自己的配置,并妥善保管。
|
||
|