只是个雏形,想接入NL的Oauth2的可以试一下,先睡了,有问题回复,我明天起来修。
NodeLoc OAuth2对接文档
概述
NL OAuth2是一个基于Discourse的SSO的身份认证服务,允许应用程序使用NL用户系统进行身份验证和授权。
快速开始
- 访问管理界面
启动成功后访问:
- Web管理: https://conn.nodeloc.cc/apps
- OAuth2授权: http://conn.nodeloc.cc/oauth2/auth
- Token端点: http://conn.nodeloc.cc/oauth2/token
应用注册
1. 通过Web界面注册
- 访问
https://conn.nodeloc.cc/apps
- 首次访问会重定向到NL进行身份验证
- 登录后点击"Create New Application"
- 填写应用信息:
应用名称: My App
描述: My OAuth2 Application
重定向URI: https://myapp.com/auth/callback
允许的组: trust_level_0 (不同的信任等级,如果你不想低等级用户使用,可选)
拒绝的组: banned (可选)
- 点击"Create Application"
- 重要: 保存显示的Client Secret,它只会显示一次
2. 获取应用凭据
注册成功后,你将获得:
- Client ID:
abcd1234567890ef
(用于标识应用) - Client Secret:
secret_xyz123
(用于应用认证) - 重定向URI: 用户授权后的回调地址
OAuth2集成
1. 授权流程
第1步: 重定向用户到授权页面
const authUrl = 'https://conn.nodeloc.cc/oauth2/auth?' +
'response_type=code&' +
'client_id=YOUR_CLIENT_ID&' +
'redirect_uri=https://myapp.com/auth/callback&' +
'scope=openid profile&' +
'state=random_state_string';
window.location.href = authUrl;
第2步: 处理授权回调
用户授权后,会重定向到你的回调URL:
https://conn.nodeloc.cc/auth/callback?code=auth_code_here&state=random_state_string
第3步: 交换访问令牌
const tokenResponse = await fetch('https://conn.nodeloc.cc/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa('CLIENT_ID:CLIENT_SECRET')
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'auth_code_here',
redirect_uri: 'https://myapp.com/auth/callback'
})
});
const tokens = await tokenResponse.json();
// tokens.access_token - 访问令牌
// tokens.id_token - ID令牌(包含用户信息)
// tokens.refresh_token - 刷新令牌
2. 用户信息获取
从ID Token解析用户信息
// 解码JWT ID Token
function parseJwt(token) {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
}
const userInfo = parseJwt(tokens.id_token);
console.log('用户ID:', userInfo.sub);
console.log('用户名:', userInfo.username);
console.log('邮箱:', userInfo.email);
console.log('用户组:', userInfo.groups);
或通过UserInfo端点
const userResponse = await fetch('https://conn.nodeloc.cc/oauth2/userinfo', {
headers: {
'Authorization': 'Bearer ' + tokens.access_token
}
});
const userInfo = await userResponse.json();
3. 令牌刷新
const refreshResponse = await fetch('https://conn.nodeloc.cc/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa('CLIENT_ID:CLIENT_SECRET')
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token
})
});
const newTokens = await refreshResponse.json();
代码示例
Node.js Express示例
const express = require('express');
const axios = require('axios');
const app = express();
// 配置
const config = {
clientId: 'your_client_id',
clientSecret: 'your_client_secret',
redirectUri: 'https://conn.nodeloc.cc/auth/callback',
authUrl: 'https://conn.nodeloc.cc/oauth2/auth',
tokenUrl: 'https://conn.nodeloc.cc/oauth2/token'
};
// 登录路由
app.get('/login', (req, res) => {
const authUrl = `${config.authUrl}?` +
`response_type=code&` +
`client_id=${config.clientId}&` +
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
`scope=openid profile&` +
`state=${Math.random().toString(36)}`;
res.redirect(authUrl);
});
// 回调路由
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
try {
// 交换访问令牌
const tokenResponse = await axios.post(config.tokenUrl,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: config.redirectUri
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')
}
}
);
const tokens = tokenResponse.data;
// 解析用户信息
const userInfo = JSON.parse(
Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
);
// 设置会话
req.session.user = userInfo;
req.session.tokens = tokens;
res.redirect('/dashboard');
} catch (error) {
res.status(500).send('Authentication failed');
}
});
app.listen(3001, () => {
console.log('App listening on port 3001');
});
Python Flask示例
from flask import Flask, request, redirect, session, jsonify
import requests
import base64
import json
app = Flask(__name__)
app.secret_key = 'your-secret-key'
# 配置
CONFIG = {
'client_id': 'your_client_id',
'client_secret': 'your_client_secret',
'redirect_uri': 'https://conn.nodeloc.cc/auth/callback',
'auth_url': 'https://conn.nodeloc.cc/oauth2/auth',
'token_url': 'https://conn.nodeloc.cc/oauth2/token'
}
@app.route('/login')
def login():
auth_url = f"{CONFIG['auth_url']}?" \
f"response_type=code&" \
f"client_id={CONFIG['client_id']}&" \
f"redirect_uri={CONFIG['redirect_uri']}&" \
f"scope=openid profile&" \
f"state=random_state"
return redirect(auth_url)
@app.route('/auth/callback')
def callback():
code = request.args.get('code')
state = request.args.get('state')
# 交换访问令牌
auth_header = base64.b64encode(
f"{CONFIG['client_id']}:{CONFIG['client_secret']}".encode()
).decode()
response = requests.post(CONFIG['token_url'],
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': CONFIG['redirect_uri']
},
headers={
'Authorization': f'Basic {auth_header}',
'Content-Type': 'application/x-www-form-urlencoded'
}
)
tokens = response.json()
# 解析用户信息
id_token_payload = tokens['id_token'].split('.')[1]
# 添加填充
id_token_payload += '=' * (4 - len(id_token_payload) % 4)
user_info = json.loads(base64.b64decode(id_token_payload))
session['user'] = user_info
session['tokens'] = tokens
return redirect('/dashboard')
@app.route('/dashboard')
def dashboard():
if 'user' not in session:
return redirect('/login')
return jsonify(session['user'])
if __name__ == '__main__':
app.run(debug=True)
API参考
授权端点
GET /oauth2/auth
获取授权码的端点。
参数:
response_type
(必需):code
client_id
(必需): 应用的Client IDredirect_uri
(必需): 授权后的回调地址scope
(可选): 请求的权限范围,建议openid profile
state
(推荐): 防CSRF的随机字符串
响应:
重定向到 redirect_uri?code=授权码&state=原state值
令牌端点
POST /oauth2/token
交换访问令牌的端点。
头部:
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
参数:
grant_type
(必需):authorization_code
或refresh_token
code
(授权码模式必需): 从授权端点获取的授权码redirect_uri
(授权码模式必需): 必须与授权时的URI一致refresh_token
(刷新令牌模式必需): 刷新令牌
响应:
{
"access_token": "访问令牌",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "刷新令牌",
"id_token": "ID令牌(JWT格式)"
}
用户信息端点
GET /oauth2/userinfo
获取用户信息的端点。
头部:
Authorization: Bearer 访问令牌
响应:
{
"sub": "用户ID",
"username": "用户名",
"email": "邮箱地址",
"groups": ["用户组1", "用户组2"]
}
应用管理API
获取应用列表
GET /api/apps
响应:
[
{
"client_id": "应用ID",
"name": "应用名称",
"description": "应用描述",
"owner_username": "创建者",
"created_at": "2024-01-01T00:00:00Z"
}
]
创建应用
POST /api/apps
请求体:
{
"name": "应用名称",
"description": "应用描述",
"redirect_uris": ["https://app.com/callback"],
"allow_groups": ["developers"],
"deny_groups": ["banned"]
}
响应:
{
"client_id": "生成的应用ID",
"client_secret": "生成的密钥",
"name": "应用名称",
"description": "应用描述"
}
安全考虑
1. 客户端密钥保护
- 永远不要在前端代码中暴露Client Secret
- 使用环境变量或安全的配置管理存储密钥
- 定期轮换客户端密钥
2. 重定向URI验证
- 使用HTTPS重定向URI (生产环境)
- 精确匹配重定向URI,不使用通配符
- 避免开放重定向漏洞
3. State参数
- 始终使用随机的state参数防止CSRF攻击
- 验证回调中的state参数
4. 令牌管理
- 安全存储访问令牌和刷新令牌
- 实现令牌过期处理
- 使用HTTPS传输令牌
故障排查
常见错误
1. “invalid_client” 错误
原因: Client ID或Client Secret错误
解决:
- 检查应用管理界面中的Client ID
- 确保Client Secret未过期
- 验证Authorization头部格式
2. “invalid_redirect_uri” 错误
原因: 重定向URI不匹配
解决:
- 确保回调URI与注册时完全一致
- 检查协议(http/https)和端口号
- 重新保存应用配置
3. “access_denied” 错误
原因: 用户拒绝授权或无权限
解决:
- 检查用户是否在允许的组中
- 确认用户未在拒绝的组中
- 引导用户重新授权
NL Oauth2 API快速参考
基础信息
- 基础URL:
https://conn.nodeloc.cc
- 协议: OAuth 2.0 + OpenID Connect 1.0
- 认证方式: Basic Auth (Client Credentials)
快速集成清单
1. 注册应用
- 访问
https://conn.nodeloc.cc/apps
- 创建新应用获取
client_id
和client_secret
- 配置回调URL
2. 实现OAuth流程
- 重定向用户到授权页面
- 处理授权回调
- 交换访问令牌
- 获取用户信息
3. 测试集成
- 完整流程测试
- 错误处理验证
- 令牌刷新测试
核心端点
授权端点
GET /oauth2/auth
参数:
参数 | 类型 | 必需 | 说明 |
---|---|---|---|
response_type |
string | ![]() |
固定值: code |
client_id |
string | ![]() |
应用Client ID |
redirect_uri |
string | ![]() |
回调地址 |
scope |
string | ![]() |
建议: openid profile |
state |
string | ![]() |
防CSRF随机字符串 |
示例:
const authUrl = 'https://conn.nodeloc.cc/oauth2/auth?' +
'response_type=code&' +
'client_id=abcd1234&' +
'redirect_uri=https://myapp.com/callback&' +
'scope=openid profile&' +
'state=xyz789';
令牌端点
POST /oauth2/token
头部:
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
获取访问令牌:
const response = await fetch('https://conn.nodeloc.cc/oauth2/token', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('client_id:client_secret'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'authorization_code_here',
redirect_uri: 'https://myapp.com/callback'
})
});
刷新令牌:
const response = await fetch('https://conn.nodeloc.cc/oauth2/token', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('client_id:client_secret'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: 'refresh_token_here'
})
});
响应:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
用户信息端点
GET /oauth2/userinfo
头部:
Authorization: Bearer access_token_here
示例:
const userResponse = await fetch('https://conn.nodeloc.cc/oauth2/userinfo', {
headers: {
'Authorization': 'Bearer ' + access_token
}
});
const userInfo = await userResponse.json();
响应:
{
"sub": "123",
"username": "john_doe",
"email": "[email protected]",
"groups": ["developers", "admins"]
}
ID Token解析
ID Token是JWT格式,包含用户信息:
function parseJwt(token) {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join('')
);
return JSON.parse(jsonPayload);
}
const idTokenData = parseJwt(tokens.id_token);
// {
// "sub": "123",
// "username": "john_doe",
// "email": "[email protected]",
// "groups": ["developers"],
// "iat": 1640995200,
// "exp": 1640998800
// }
错误处理
常见错误代码
错误 | HTTP状态 | 说明 | 解决方案 |
---|---|---|---|
invalid_client |
401 | Client ID/Secret错误 | 检查应用凭据 |
invalid_grant |
400 | 授权码无效/过期 | 重新获取授权码 |
invalid_redirect_uri |
400 | 回调URI不匹配 | 检查注册的URI |
access_denied |
403 | 用户拒绝授权 | 引导用户重新授权 |
unsupported_grant_type |
400 | 不支持的授权类型 | 使用正确的grant_type |
错误响应格式
{
"error": "invalid_client",
"error_description": "Client authentication failed"
}
完整示例
JavaScript/Node.js
class DistrustClient {
constructor(clientId, clientSecret, redirectUri) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.baseUrl = 'https://conn.nodeloc.cc';
}
// 生成授权URL
getAuthUrl(state = null) {
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: 'openid profile'
});
if (state) params.append('state', state);
return `${this.baseUrl}/oauth2/auth?${params}`;
}
// 交换访问令牌
async exchangeCodeForTokens(code) {
const response = await fetch(`${this.baseUrl}/oauth2/token`, {
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.redirectUri
})
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
return await response.json();
}
// 获取用户信息
async getUserInfo(accessToken) {
const response = await fetch(`${this.baseUrl}/oauth2/userinfo`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error(`Get user info failed: ${response.status}`);
}
return await response.json();
}
// 刷新令牌
async refreshToken(refreshToken) {
const response = await fetch(`${this.baseUrl}/oauth2/token`, {
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
})
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
return await response.json();
}
}
// 使用示例
const client = new DistrustClient(
'your_client_id',
'your_client_secret',
'https://yourapp.com/auth/callback'
);
// 1. 重定向到授权页面
const authUrl = client.getAuthUrl('random_state_123');
// window.location.href = authUrl;
// 2. 处理回调 (在你的回调路由中)
async function handleCallback(code) {
try {
const tokens = await client.exchangeCodeForTokens(code);
const userInfo = await client.getUserInfo(tokens.access_token);
console.log('用户信息:', userInfo);
// 保存tokens和用户信息到会话
} catch (error) {
console.error('OAuth错误:', error);
}
}
Python示例
import requests
import base64
import json
from urllib.parse import urlencode
class DistrustClient:
def __init__(self, client_id, client_secret, redirect_uri):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.base_url = 'https://conn.nodeloc.cc'
def get_auth_url(self, state=None):
params = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'scope': 'openid profile'
}
if state:
params['state'] = state
return f"{self.base_url}/oauth2/auth?{urlencode(params)}"
def exchange_code_for_tokens(self, code):
auth_header = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
response = requests.post(
f"{self.base_url}/oauth2/token",
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.redirect_uri
},
headers={
'Authorization': f'Basic {auth_header}',
'Content-Type': 'application/x-www-form-urlencoded'
}
)
response.raise_for_status()
return response.json()
def get_user_info(self, access_token):
response = requests.get(
f"{self.base_url}/oauth2/userinfo",
headers={'Authorization': f'Bearer {access_token}'}
)
response.raise_for_status()
return response.json()
# 使用示例
client = DistrustClient(
'your_client_id',
'your_client_secret',
'https://yourapp.com/auth/callback'
)
# 生成授权URL
auth_url = client.get_auth_url('random_state_123')
print(f"授权URL: {auth_url}")
# 处理回调
def handle_callback(code):
tokens = client.exchange_code_for_tokens(code)
user_info = client.get_user_info(tokens['access_token'])
return user_info
测试工具
cURL命令
# 1. 获取授权码 (在浏览器中访问)
# https://conn.nodeloc.cc/oauth2/auth?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https://yourapp.com/callback&scope=openid
# 2. 交换访问令牌
curl -X POST https://conn.nodeloc.cc/oauth2/token \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=https://yourapp.com/callback"
# 3. 获取用户信息
curl -H "Authorization: Bearer ACCESS_TOKEN" \
https://conn.nodeloc.cc/oauth2/userinfo
在线测试工具
使用OpenID Connect调试器:
- 访问 https://oidcdebugger.com/
- 填入配置:
- Authorize URI:
https://conn.nodeloc.cc/oauth2/auth
- Client ID: 你的应用ID
- Scope:
openid profile
- Response Type:
code
- Authorize URI:
最后更新: 2025年5月