Nodeloc支持佬友们通过Oauth2申请自己的Application啦

OAuth2 Applications

只是个雏形,想接入NL的Oauth2的可以试一下,先睡了,有问题回复,我明天起来修。

:xhj006:

NodeLoc OAuth2对接文档

概述

NL OAuth2是一个基于Discourse的SSO的身份认证服务,允许应用程序使用NL用户系统进行身份验证和授权。

快速开始

  1. 访问管理界面

启动成功后访问:

应用注册

1. 通过Web界面注册

  1. 访问 https://conn.nodeloc.cc/apps
  2. 首次访问会重定向到NL进行身份验证
  3. 登录后点击"Create New Application"
  4. 填写应用信息:
应用名称: My App
描述: My OAuth2 Application
重定向URI: https://myapp.com/auth/callback
允许的组: trust_level_0 (不同的信任等级,如果你不想低等级用户使用,可选)
拒绝的组: banned (可选)
  1. 点击"Create Application"
  2. 重要: 保存显示的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 ID
  • redirect_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_coderefresh_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)

快速集成清单

:white_check_mark: 1. 注册应用

  1. 访问 https://conn.nodeloc.cc/apps
  2. 创建新应用获取 client_idclient_secret
  3. 配置回调URL

:white_check_mark: 2. 实现OAuth流程

  1. 重定向用户到授权页面
  2. 处理授权回调
  3. 交换访问令牌
  4. 获取用户信息

:white_check_mark: 3. 测试集成

  1. 完整流程测试
  2. 错误处理验证
  3. 令牌刷新测试

核心端点

:locked_with_key: 授权端点

GET /oauth2/auth

参数:

参数 类型 必需 说明
response_type string :white_check_mark: 固定值: code
client_id string :white_check_mark: 应用Client ID
redirect_uri string :white_check_mark: 回调地址
scope string :white_circle: 建议: openid profile
state string :white_circle: 防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';

:ticket: 令牌端点

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..."
}

:bust_in_silhouette: 用户信息端点

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调试器:

  1. 访问 https://oidcdebugger.com/
  2. 填入配置:
    • Authorize URI: https://conn.nodeloc.cc/oauth2/auth
    • Client ID: 你的应用ID
    • Scope: openid profile
    • Response Type: code

最后更新: 2025年5月

10 个赞

为什么点开就是 502 Bad Gateway,是我等级不够吗
:ac15:

不是,等等我在重启,不要急不要急 :xhj27:

:xhj13:

好了呢,试一下。

很高级,等待大佬开发出他的功能,我是小白 :xhj006:

补充了文档,AGC,将就看哈。

方便一些福利资源nl内部消化了 :ac01:

我的账号登录 free 显示了一次授权页,Allow 后还是无限返回登录页。 :xhj42:

这是free这边的对接问题了

等我搞个对接下

1 个赞

学习学习~ :ac01:

应该搞一个 key 分发
有利于福利分享

自己创建应用就可以了

第三方还能获取邮箱啊,最好还是不要给吧,这个比较隐私吧

授权了才有的。就好像你在第三方平台注册的时候,一般也需要邮箱的。 主要还是看对第三方的服务感不感兴趣。要不要授权。

支持,坚决支持

PHP SDK

1 个赞

效果相当Very Good

我太难了文字太多不想看了,看样子对接不了了 哈哈