Files
new-api/docs/oauth2-demo.md
2025-09-08 12:09:26 +08:00

24 KiB
Raw Blame History

OAuth2服务端使用Demo - 自动登录流程

本文档演示如何使用new-api的OAuth2服务器实现自动登录功能包括两种授权模式的完整流程。

📋 准备工作

1. 启用OAuth2服务器

在管理后台 -> 设置 -> OAuth2 & SSO 中:

启用OAuth2服务器: 开启
签发者标识(Issuer): https://your-domain.com
访问令牌有效期: 60分钟
刷新令牌有效期: 24小时
JWT签名算法: RS256
允许的授权类型: client_credentials, authorization_code

2. 创建OAuth2客户端

在OAuth2客户端管理中创建应用

客户端名称: My App
客户端类型: 机密客户端 (Confidential)
授权类型: Client Credentials, Authorization Code
权限范围: api:read, api:write
重定向URI: https://your-app.com/callback

创建成功后会获得:

  • Client ID: your_client_id
  • Client Secret: your_client_secret (仅显示一次)

🔐 方式一:客户端凭证流程 (Client Credentials)

适用于服务器到服务器的API调用无需用户交互。

获取访问令牌

curl -X POST https://your-domain.com/api/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=your_client_id" \
  -d "client_secret=your_client_secret" \
  -d "scope=api:read api:write"

响应示例:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "api:read api:write"
}

使用访问令牌调用API

curl -X GET https://your-domain.com/api/user/self \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

👤 方式二:授权码流程 (Authorization Code + PKCE)

适用于用户登录场景,支持自动登录功能。

Step 1: 生成PKCE参数

// 生成随机code_verifier
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode.apply(null, array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// 生成code_challenge
async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

Step 2: 重定向用户到授权页面

const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);

// 保存code_verifier到本地存储
localStorage.setItem('oauth_code_verifier', codeVerifier);

// 构建授权URL
const authUrl = new URL('https://your-domain.com/api/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your_client_id');
authUrl.searchParams.set('redirect_uri', 'https://your-app.com/callback');
authUrl.searchParams.set('scope', 'api:read api:write');
authUrl.searchParams.set('state', 'random_state_value');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');

// 重定向到授权页面
window.location.href = authUrl.toString();

Step 3: 处理授权回调

用户授权后会跳转到https://your-app.com/callback?code=xxx&state=xxx

// 在callback页面处理授权码
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const codeVerifier = localStorage.getItem('oauth_code_verifier');

if (code && codeVerifier) {
  // 交换访问令牌
  const tokenResponse = await fetch('https://your-domain.com/api/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'your_client_id',
      client_secret: 'your_client_secret',
      code: code,
      redirect_uri: 'https://your-app.com/callback',
      code_verifier: codeVerifier
    })
  });

  const tokens = await tokenResponse.json();
  
  // 解析JWT令牌获取用户信息
  const userInfo = parseJWTToken(tokens.access_token);
  console.log('用户信息:', userInfo);
  
  // 保存令牌和用户信息
  localStorage.setItem('access_token', tokens.access_token);
  localStorage.setItem('refresh_token', tokens.refresh_token);
  localStorage.setItem('user_info', JSON.stringify(userInfo));
  
  // 清理临时数据
  localStorage.removeItem('oauth_code_verifier');
  
  // 跳转到应用首页
  window.location.href = '/dashboard';
}

Step 4: JWT令牌解析和用户信息获取

授权码流程返回的access_token是一个JWT令牌包含用户信息

// JWT令牌解析函数
function parseJWTToken(token) {
  try {
    // JWT格式: header.payload.signature
    const parts = token.split('.');
    if (parts.length !== 3) {
      throw new Error('Invalid JWT token format');
    }

    // 解码payload部分
    const payload = JSON.parse(atob(parts[1]));
    
    // 提取用户信息
    return {
      userId: payload.sub,           // 用户ID
      username: payload.preferred_username || payload.sub,
      email: payload.email,          // 用户邮箱
      name: payload.name,            // 用户姓名
      roles: payload.scope?.split(' ') || [], // 权限范围
      groups: payload.groups || [],   // 用户组
      exp: payload.exp,              // 过期时间
      iat: payload.iat,              // 签发时间
      iss: payload.iss,              // 签发者
      aud: payload.aud               // 受众
    };
  } catch (error) {
    console.error('Failed to parse JWT token:', error);
    return null;
  }
}

// JWT令牌验证函数
function validateJWTToken(token) {
  const userInfo = parseJWTToken(token);
  if (!userInfo) return false;
  
  // 检查令牌是否过期
  const now = Math.floor(Date.now() / 1000);
  if (userInfo.exp && now >= userInfo.exp) {
    console.log('JWT token has expired');
    return false;
  }
  
  return true;
}

// 获取用户信息示例
async function getUserInfoFromToken() {
  const token = localStorage.getItem('access_token');
  if (!token) return null;
  
  if (!validateJWTToken(token)) {
    // 令牌无效或过期,尝试刷新
    const newToken = await refreshToken();
    if (newToken) {
      return parseJWTToken(newToken);
    }
    return null;
  }
  
  return parseJWTToken(token);
}

JWT令牌示例内容:

{
  "sub": "user123",                    // 用户唯一标识
  "preferred_username": "john_doe",    // 用户名
  "email": "john@example.com",         // 邮箱
  "name": "John Doe",                  // 真实姓名
  "scope": "api:read api:write",       // 权限范围
  "groups": ["users", "developers"],   // 用户组
  "iss": "https://your-domain.com",    // 签发者
  "aud": "your_client_id",             // 受众
  "exp": 1609459200,                   // 过期时间戳
  "iat": 1609455600,                   // 签发时间戳
  "jti": "token-unique-id"             // 令牌唯一ID
}

👤 自动创建用户登录流程

用户信息收集和自动创建

当启用了AutoCreateUser选项时用户首次通过OAuth2授权后会自动创建账户

// 用户信息收集表单
function showUserInfoForm(jwtUserInfo) {
  const formHTML = `
    <div id="userInfoForm" style="max-width: 400px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
      <h3>完善用户信息</h3>
      <p>系统将为您自动创建账户,请填写或确认以下信息:</p>
      
      <form id="userRegistrationForm">
        <div style="margin-bottom: 15px;">
          <label>用户名 <span style="color: red;">*</span></label>
          <input type="text" id="username" value="${jwtUserInfo.username || ''}" required 
                 style="width: 100%; padding: 8px; margin-top: 5px;">
          <small>用于登录的用户名</small>
        </div>
        
        <div style="margin-bottom: 15px;">
          <label>显示名称</label>
          <input type="text" id="displayName" value="${jwtUserInfo.name || jwtUserInfo.username || ''}"
                 style="width: 100%; padding: 8px; margin-top: 5px;">
          <small>在界面上显示的名称</small>
        </div>
        
        <div style="margin-bottom: 15px;">
          <label>邮箱地址</label>
          <input type="email" id="email" value="${jwtUserInfo.email || ''}"
                 style="width: 100%; padding: 8px; margin-top: 5px;">
          <small>用于接收通知和找回密码</small>
        </div>
        
        <div style="margin-bottom: 15px;">
          <label>所属组织</label>
          <input type="text" id="group" value="oauth2" readonly
                 style="width: 100%; padding: 8px; margin-top: 5px; background: #f5f5f5;">
          <small>OAuth2自动创建的用户组</small>
        </div>
        
        <div style="margin-bottom: 20px;">
          <h4>从OAuth2提供商获取的信息</h4>
          <pre style="background: #f8f9fa; padding: 10px; border-radius: 4px; font-size: 12px;">
${JSON.stringify(jwtUserInfo, null, 2)}
          </pre>
        </div>
        
        <button type="submit" style="background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer;">
          创建账户并登录
        </button>
        <button type="button" onclick="cancelRegistration()" style="background: #6c757d; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin-left: 10px;">
          取消
        </button>
      </form>
    </div>
  `;
  
  document.body.innerHTML = formHTML;
  
  // 绑定表单提交事件
  document.getElementById('userRegistrationForm').addEventListener('submit', handleUserRegistration);
}

// 处理用户注册
async function handleUserRegistration(event) {
  event.preventDefault();
  
  const formData = {
    username: document.getElementById('username').value.trim(),
    displayName: document.getElementById('displayName').value.trim(),
    email: document.getElementById('email').value.trim(),
    group: document.getElementById('group').value,
    oauth2Provider: 'oauth2',
    oauth2UserId: parseJWTToken(localStorage.getItem('access_token')).userId
  };
  
  try {
    // 调用自动创建用户API
    const response = await fetch('https://your-domain.com/api/oauth/auto_create_user', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${localStorage.getItem('access_token')}`
      },
      body: JSON.stringify(formData)
    });
    
    const result = await response.json();
    
    if (result.success) {
      // 用户创建成功,跳转到主界面
      localStorage.setItem('user_created', 'true');
      window.location.href = '/dashboard';
    } else {
      alert('创建用户失败: ' + result.message);
    }
  } catch (error) {
    console.error('用户创建失败:', error);
    alert('创建用户时发生错误,请重试');
  }
}

// 取消注册
function cancelRegistration() {
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  window.location.href = '/';
}

完整的自动登录流程

// 改进的自动登录初始化
async function initAutoLogin() {
  try {
    // 1. 检查是否有有效的访问令牌
    const accessToken = localStorage.getItem('access_token');
    if (!accessToken || !validateJWTToken(accessToken)) {
      // 没有有效令牌开始OAuth2授权流程
      startOAuth2Authorization();
      return;
    }
    
    // 2. 解析JWT令牌获取用户信息
    const jwtUserInfo = parseJWTToken(accessToken);
    console.log('JWT用户信息:', jwtUserInfo);
    
    // 3. 检查用户是否已存在于系统中
    const userExists = await checkUserExists(jwtUserInfo.userId);
    
    if (!userExists && !localStorage.getItem('user_created')) {
      // 4. 用户不存在且未创建,显示用户信息收集表单
      showUserInfoForm(jwtUserInfo);
      return;
    }
    
    // 5. 用户已存在或已创建,直接登录
    const apiUserInfo = await oauth2Client.callAPI('/api/user/self');
    console.log('API用户信息:', apiUserInfo);
    
    // 6. 显示主界面
    showDashboard(jwtUserInfo, apiUserInfo);
    
  } catch (error) {
    console.error('自动登录失败:', error);
    // 清理令牌并重新开始授权流程
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    localStorage.removeItem('user_created');
    startOAuth2Authorization();
  }
}

// 检查用户是否存在
async function checkUserExists(userId) {
  try {
    const response = await fetch(`https://your-domain.com/api/oauth/user_exists/${userId}`, {
      headers: {
        'Authorization': `Bearer ${localStorage.getItem('access_token')}`
      }
    });
    
    const result = await response.json();
    return result.exists;
  } catch (error) {
    console.error('检查用户存在性失败:', error);
    return false;
  }
}

// 开始OAuth2授权流程
function startOAuth2Authorization() {
  const oauth2Client = new OAuth2Client({
    clientId: 'your_client_id',
    clientSecret: 'your_client_secret',
    serverUrl: 'https://your-domain.com',
    redirectUri: window.location.origin + '/callback',
    scopes: 'api:read api:write'
  });
  
  oauth2Client.startAuthorizationCodeFlow();
}

服务器端自动创建用户API

需要在服务器端实现相应的API端点

// 用户存在性检查
func CheckUserExists(c *gin.Context) {
    oauthUserId := c.Param("oauth_user_id")
    
    var user model.User
    err := model.DB.Where("oauth2_user_id = ?", oauthUserId).First(&user).Error
    
    if errors.Is(err, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusOK, gin.H{
            "exists": false,
        })
    } else if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Database error",
        })
    } else {
        c.JSON(http.StatusOK, gin.H{
            "exists": true,
            "user_id": user.Id,
        })
    }
}

// 自动创建用户
func AutoCreateUser(c *gin.Context) {
    settings := system_setting.GetOAuth2Settings()
    if !settings.AutoCreateUser {
        c.JSON(http.StatusForbidden, gin.H{
            "success": false,
            "message": "自动创建用户功能未启用",
        })
        return
    }
    
    var req struct {
        Username      string `json:"username" binding:"required"`
        DisplayName   string `json:"displayName"`
        Email         string `json:"email"`
        Group         string `json:"group"`
        OAuth2UserId  string `json:"oauth2UserId" binding:"required"`
    }
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "success": false,
            "message": "无效的请求参数",
        })
        return
    }
    
    // 检查用户是否已存在
    var existingUser model.User
    err := model.DB.Where("username = ? OR oauth2_user_id = ?", req.Username, req.OAuth2UserId).First(&existingUser).Error
    if err == nil {
        c.JSON(http.StatusConflict, gin.H{
            "success": false,
            "message": "用户已存在",
        })
        return
    }
    
    // 创建新用户
    user := model.User{
        Username:      req.Username,
        DisplayName:   req.DisplayName,
        Email:         req.Email,
        Group:         settings.DefaultUserGroup,
        Role:          settings.DefaultUserRole,
        Status:        1,
        Password:      common.GenerateRandomString(32), // 随机密码用户通过OAuth2登录
        OAuth2UserId:  req.OAuth2UserId,
    }
    
    if req.DisplayName == "" {
        user.DisplayName = req.Username
    }
    
    if user.Group == "" {
        user.Group = "oauth2"
    }
    
    err = user.Insert(0)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "success": false,
            "message": "创建用户失败: " + err.Error(),
        })
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "message": "用户创建成功",
        "user_id": user.Id,
    })
}

🔄 自动登录实现

令牌刷新机制

async function refreshToken() {
  const refreshToken = localStorage.getItem('refresh_token');
  
  if (!refreshToken) {
    // 重新授权
    redirectToAuth();
    return;
  }

  try {
    const response = await fetch('https://your-domain.com/api/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: 'your_client_id',
        client_secret: 'your_client_secret',
        refresh_token: refreshToken
      })
    });

    const tokens = await response.json();
    
    if (tokens.access_token) {
      localStorage.setItem('access_token', tokens.access_token);
      if (tokens.refresh_token) {
        localStorage.setItem('refresh_token', tokens.refresh_token);
      }
      return tokens.access_token;
    }
  } catch (error) {
    // 刷新失败,重新授权
    redirectToAuth();
  }
}

自动认证拦截器

class OAuth2Client {
  constructor(clientId, clientSecret, baseURL) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.baseURL = baseURL;
  }

  // 自动处理认证的请求方法
  async request(url, options = {}) {
    let accessToken = localStorage.getItem('access_token');
    
    // 检查令牌是否即将过期
    if (this.isTokenExpiringSoon(accessToken)) {
      accessToken = await this.refreshToken();
    }

    // 添加认证头
    const headers = {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      ...options.headers
    };

    try {
      const response = await fetch(`${this.baseURL}${url}`, {
        ...options,
        headers
      });

      // 如果401尝试刷新令牌
      if (response.status === 401) {
        accessToken = await this.refreshToken();
        headers['Authorization'] = `Bearer ${accessToken}`;
        
        // 重试请求
        return fetch(`${this.baseURL}${url}`, {
          ...options,
          headers
        });
      }

      return response;
    } catch (error) {
      console.error('Request failed:', error);
      throw error;
    }
  }

  // 检查令牌是否即将过期
  isTokenExpiringSoon(token) {
    if (!token) return true;
    
    try {
      const parts = token.split('.');
      if (parts.length !== 3) return true;
      
      const payload = JSON.parse(atob(parts[1]));
      const exp = payload.exp * 1000; // 转换为毫秒
      const now = Date.now();
      return exp - now < 5 * 60 * 1000; // 5分钟内过期
    } catch (error) {
      console.error('Token validation failed:', error);
      return true;
    }
  }

  // 获取当前用户信息
  getCurrentUser() {
    const token = localStorage.getItem('access_token');
    if (!token || !this.validateJWTToken(token)) {
      return null;
    }
    
    return this.parseJWTToken(token);
  }

  // 解析JWT令牌
  parseJWTToken(token) {
    try {
      const parts = token.split('.');
      if (parts.length !== 3) {
        throw new Error('Invalid JWT token format');
      }

      const payload = JSON.parse(atob(parts[1]));
      
      return {
        userId: payload.sub,
        username: payload.preferred_username || payload.sub,
        email: payload.email,
        name: payload.name,
        roles: payload.scope?.split(' ') || [],
        groups: payload.groups || [],
        exp: payload.exp,
        iat: payload.iat,
        iss: payload.iss,
        aud: payload.aud
      };
    } catch (error) {
      console.error('Failed to parse JWT token:', error);
      return null;
    }
  }

  // 验证JWT令牌
  validateJWTToken(token) {
    const userInfo = this.parseJWTToken(token);
    if (!userInfo) return false;
    
    const now = Math.floor(Date.now() / 1000);
    if (userInfo.exp && now >= userInfo.exp) {
      return false;
    }
    
    return true;
  }

  // 获取用户信息
  async getUserInfo() {
    const response = await this.request('/api/user/self');
    return response.json();
  }

  // 调用API示例
  async callAPI(endpoint, data = null) {
    const options = data ? {
      method: 'POST',
      body: JSON.stringify(data)
    } : { method: 'GET' };

    const response = await this.request(endpoint, options);
    return response.json();
  }
}

使用示例

// 初始化OAuth2客户端
const oauth2Client = new OAuth2Client(
  'your_client_id',
  'your_client_secret',
  'https://your-domain.com'
);

// 应用启动时自动检查登录状态
async function initApp() {
  try {
    // 尝试获取用户信息(会自动处理令牌刷新)
    const userInfo = await oauth2Client.getUserInfo();
    console.log('User logged in:', userInfo);
    
    // 显示用户界面
    showDashboard(userInfo);
  } catch (error) {
    // 用户未登录,重定向到授权页面
    redirectToAuth();
  }
}

// 页面加载时初始化
document.addEventListener('DOMContentLoaded', initApp);

🛡️ 安全最佳实践

1. HTTPS 必需

生产环境必须使用HTTPS
重定向URI必须使用https://本地开发可用http://localhost

2. 状态参数验证

// 发起授权时
const state = crypto.randomUUID();
localStorage.setItem('oauth_state', state);

// 回调时验证
const returnedState = urlParams.get('state');
const savedState = localStorage.getItem('oauth_state');
if (returnedState !== savedState) {
  throw new Error('State mismatch - possible CSRF attack');
}

3. 令牌安全存储

// 使用HttpOnly Cookie推荐
// 或加密存储在localStorage
function secureStorage() {
  return {
    setItem: (key, value) => {
      const encrypted = encrypt(value); // 使用加密
      localStorage.setItem(key, encrypted);
    },
    getItem: (key) => {
      const encrypted = localStorage.getItem(key);
      return encrypted ? decrypt(encrypted) : null;
    }
  };
}

📚 完整示例项目

创建一个完整的单页应用示例:

<!DOCTYPE html>
<html>
<head>
    <title>OAuth2 Demo</title>
</head>
<body>
    <div id="login-section">
        <h1>请登录</h1>
        <button onclick="login()">使用OAuth2登录</button>
    </div>
    
    <div id="app-section" style="display:none">
        <h1>欢迎!</h1>
        <div id="user-info"></div>
        <button onclick="logout()">登出</button>
        <button onclick="testAPI()">测试API调用</button>
    </div>

    <script>
        // 这里包含上面的所有OAuth2Client代码
        
        const oauth2Client = new OAuth2Client(
            'your_client_id',
            'your_client_secret',
            'https://your-domain.com'
        );

        async function login() {
            // 实现授权码流程...
        }

        async function logout() {
            localStorage.clear();
            location.reload();
        }

        async function testAPI() {
            try {
                const result = await oauth2Client.callAPI('/api/user/self');
                alert('API调用成功: ' + JSON.stringify(result));
            } catch (error) {
                alert('API调用失败: ' + error.message);
            }
        }

        // 初始化应用
        initApp();
    </script>
</body>
</html>

🔍 调试和测试

验证JWT令牌

访问 jwt.io 解析令牌内容:

Header: {"alg":"RS256","typ":"JWT","kid":"oauth2-key-1"}
Payload: {"sub":"user_id","aud":"your_client_id","exp":1234567890}

查看服务器信息

curl https://your-domain.com/.well-known/oauth-authorization-server

获取JWKS公钥

curl https://your-domain.com/.well-known/jwks.json

这个demo涵盖了OAuth2服务器的完整使用流程实现了真正的自动登录功能。用户只需要第一次授权之后应用会自动处理令牌刷新和API认证。