React + Go 全栈集成教程
本教程介绍如何将一个 React + Go 前后端分离项目接入 Code Bird Cloud,实现完整的用户认证和管理功能。
Go 后端负责 OIDC 令牌交换和 Session 管理,React 前端通过后端 API 完成登录/登出,Access Token 始终保存在服务端,安全性最高。
架构概览
┌──────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ React 前端 │ │ Go 后端 (Gin) │ │ Code Bird Cloud │
│ localhost:5173 │ │ localhost:8080 │ │ your-cbc.com │
├──────────────────┤ ├──────────────────────┤ ├─────────────────────┤
│ │ │ │ │ │
│ 1. 点击登录 ──────────> GET /auth/login │ │ │
│ │ │ 生成 state ──────────────> /oidc/authorize │
│ │ │ │ │ 重定向到登录页 │
│ │ │ │ │ │
│ (用户在 Code Bird Cloud 登录页完成认证) │ │
│ │ │ │ │ │
│ │ │ GET /auth/callback <────── 携带 code 回调 │
│ │ │ 用 code 换 token ────────> POST /oidc/token │
│ │ │ token 存入 session <───── 返回 JWT 令牌 │
│ <── 重定向到前端 ──────── 302 → localhost:5173 │ │ │
│ │ │ │ │ │
│ 2. 获取用户信息 ──────> GET /api/me │ │ │
│ <── 返回用户数据 ──────── 从 session 读取 │ │ │
│ │ │ │ │ │
│ 3. 调用业务 API ──────> GET /api/xxx │ │ │
│ │ │ 验证 session ✓ │ │ │
│ <── 返回业务数据 ──────── 处理业务逻辑 │ │ │
│ │ │ │ │ │
│ 4. 登出 ─────────────> GET /auth/logout │ │ │
│ │ │ 吊销 token ──────────────> POST /oidc/revoke │
│ │ │ 清除 session ────────────> GET /oidc/end-session│
│ <── 重定向到首页 ──────── 302 → localhost:5173 │ │ │
│ │ │ │ │ │
└──────────────────┘ └──────────────────────┘ └─────────────────────┘前置条件
- Go 1.21+
- Node.js 18+, pnpm
- 一个运行中的 Code Bird Cloud 实例
- 已创建租户和租户管理员
第 1 步:在管理后台创建应用
登录 Admin Console,创建以下应用:
1.1 创建 Traditional Web 应用(用户认证)
| 配置项 | 值 |
|---|---|
| 应用名称 | 你的项目名称 |
| 应用类型 | Traditional Web |
| Redirect URI | http://localhost:8080/auth/callback |
| Post-logout Redirect URI | http://localhost:5173 |
创建后记录:
- Client ID
- Client Secret
1.2 创建 M2M 应用(后端管理用户,可选)
如果 Go 后端需要主动管理用户(查询用户列表、创建用户、禁用用户等),额外创建:
| 配置项 | 值 |
|---|---|
| 应用名称 | 你的项目-后端服务 |
| 应用类型 | Machine-to-Machine |
| 角色 | 分配包含 Management API 权限的角色 |
创建后记录 M2M 应用的 Client ID 和 Client Secret。
第 2 步:Go 后端实现
2.1 项目结构
your-project-backend/
├── main.go
├── internal/
│ ├── auth/
│ │ ├── oidc.go # OIDC 配置和初始化
│ │ ├── handlers.go # 登录/回调/登出路由
│ │ ├── middleware.go # 认证中间件
│ │ └── m2m.go # M2M token 管理(可选)
│ └── api/
│ └── handlers.go # 业务 API
├── go.mod
└── .env2.2 安装依赖
bash
go get github.com/gin-gonic/gin
go get github.com/coreos/go-oidc/v3/oidc
go get golang.org/x/oauth2
go get github.com/gorilla/sessions
go get github.com/joho/godotenv2.3 环境变量
bash
# .env
# Code Bird Cloud 地址
CBC_ISSUER=https://your-cbc.com
# Traditional Web 应用凭证
CBC_CLIENT_ID=your_client_id
CBC_CLIENT_SECRET=your_client_secret
CBC_REDIRECT_URI=http://localhost:8080/auth/callback
# 前端地址(CORS 和重定向用)
FRONTEND_URL=http://localhost:5173
# Session 密钥(生产环境必须更换)
SESSION_SECRET=change-me-to-a-random-string
# M2M 应用凭证(可选,管理用户时需要)
CBC_M2M_CLIENT_ID=your_m2m_client_id
CBC_M2M_CLIENT_SECRET=your_m2m_client_secret2.4 OIDC 配置和初始化
go
// internal/auth/oidc.go
package auth
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
)
type OIDCAuth struct {
Provider *oidc.Provider
OAuth2Config oauth2.Config
Verifier *oidc.IDTokenVerifier
Store *sessions.CookieStore
}
func NewOIDCAuth(ctx context.Context) (*OIDCAuth, error) {
issuer := os.Getenv("CBC_ISSUER")
clientID := os.Getenv("CBC_CLIENT_ID")
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("failed to init OIDC provider: %w", err)
}
oauth2Config := oauth2.Config{
ClientID: clientID,
ClientSecret: os.Getenv("CBC_CLIENT_SECRET"),
RedirectURL: os.Getenv("CBC_REDIRECT_URI"),
Endpoint: provider.Endpoint(),
Scopes: []string{
oidc.ScopeOpenID,
"profile",
"email",
"phone", // 获取用户手机号(phone_number, phone_number_verified)
"offline_access",
"urn:codebird:scope:organizations",
"urn:codebird:scope:organization_roles",
},
}
verifier := provider.Verifier(&oidc.Config{
ClientID: clientID,
// Code Bird Cloud 使用 ES256,go-oidc 会自动从 JWKS 获取算法
})
// ⚠️ 注意:CookieStore 有 4KB 大小限制。
// OIDC token + userinfo 序列化后通常超过此限制,导致 session 数据丢失。
// 生产环境建议使用 Redis 等服务端 session 存储(如 github.com/rbcervilla/redisstore/v9)。
store := sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400, // 24 hours
HttpOnly: true,
// SameSite 必须为 Lax(不能是 Strict):
// OIDC 回调是从 Code Bird Cloud 重定向过来的跨站导航,
// Strict 模式下浏览器不会携带 cookie,导致 state 验证失败。
SameSite: http.SameSiteLaxMode,
Secure: false, // 生产环境(HTTPS)必须设为 true
}
return &OIDCAuth{
Provider: provider,
OAuth2Config: oauth2Config,
Verifier: verifier,
Store: store,
}, nil
}2.5 登录/回调/登出路由
go
// internal/auth/handlers.go
package auth
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)
const sessionName = "auth-session"
// UserInfo 存储在 Session 中的用户信息
type UserInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Picture string `json:"picture"`
Gender int `json:"gender,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
Organizations []string `json:"organizations,omitempty"`
OrganizationRoles []string `json:"organization_roles,omitempty"` // 格式: ["org_id:role_name", ...]
}
// generateState 生成 CSRF 安全的 state 参数
func generateState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// HandleLogin 发起 OIDC 登录
func (a *OIDCAuth) HandleLogin(c *gin.Context) {
state, err := generateState()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
return
}
// 将 state 存入 session 用于回调验证
session, _ := a.Store.Get(c.Request, sessionName)
session.Values["state"] = state
session.Values["state_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := session.Save(c.Request, c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save session"})
return
}
// 构建授权 URL;如果需要组织级登录,附带 organization_code 参数
var extraParams []oauth2.AuthCodeOption
if orgCode := os.Getenv("CBC_ORGANIZATION_CODE"); orgCode != "" {
extraParams = append(extraParams, oauth2.SetAuthURLParam("organization_code", orgCode))
}
authURL := a.OAuth2Config.AuthCodeURL(state, extraParams...)
http.Redirect(c.Writer, c.Request, authURL, http.StatusFound)
}
// 组织级登录说明:
// - organization_code 和 organization_id 两者等价,传入其一即可
// - 传入后用户必须是该组织的成员才能登录
// - Token 中的 organization_roles 仅包含该组织的角色
// - 格式为 "org_id:role_name" 字符串,可按 ":" 分割提取角色名
// HandleCallback 处理 OIDC 回调
func (a *OIDCAuth) HandleCallback(c *gin.Context) {
ctx := c.Request.Context()
// 验证 state 防止 CSRF
session, _ := a.Store.Get(c.Request, sessionName)
savedState, ok := session.Values["state"].(string)
if !ok || savedState != c.Query("state") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state parameter"})
return
}
// 检查 state 是否过期
if expires, ok := session.Values["state_expires"].(int64); ok {
if time.Now().Unix() > expires {
c.JSON(http.StatusBadRequest, gin.H{"error": "state expired"})
return
}
}
// 清除已使用的 state
delete(session.Values, "state")
delete(session.Values, "state_expires")
// 检查授权错误
if errMsg := c.Query("error"); errMsg != "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": errMsg,
"description": c.Query("error_description"),
})
return
}
// 用授权码换取令牌
code := c.Query("code")
token, err := a.OAuth2Config.Exchange(ctx, code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed: " + err.Error()})
return
}
// 验证 ID Token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
return
}
idToken, err := a.Verifier.Verify(ctx, rawIDToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "id_token verification failed: " + err.Error()})
return
}
// 解析用户信息
var userInfo UserInfo
if err := idToken.Claims(&userInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse claims"})
return
}
// 将令牌和用户信息存入 Session
userInfoJSON, _ := json.Marshal(userInfo)
session.Values["user_info"] = string(userInfoJSON)
session.Values["access_token"] = token.AccessToken
session.Values["refresh_token"] = token.RefreshToken
session.Values["id_token"] = rawIDToken
session.Values["token_expiry"] = token.Expiry.Unix()
if err := session.Save(c.Request, c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save session"})
return
}
// 重定向到前端
frontendURL := os.Getenv("FRONTEND_URL")
http.Redirect(c.Writer, c.Request, frontendURL, http.StatusFound)
}
// HandleLogout 登出
func (a *OIDCAuth) HandleLogout(c *gin.Context) {
session, _ := a.Store.Get(c.Request, sessionName)
// 吊销 Refresh Token
if refreshToken, ok := session.Values["refresh_token"].(string); ok && refreshToken != "" {
a.revokeToken(refreshToken, "refresh_token")
}
// 吊销 Access Token
if accessToken, ok := session.Values["access_token"].(string); ok && accessToken != "" {
a.revokeToken(accessToken, "access_token")
}
// 获取 id_token 用于 end-session
idToken, _ := session.Values["id_token"].(string)
// 清除 Session
session.Options.MaxAge = -1
session.Save(c.Request, c.Writer)
// 重定向到 Code Bird Cloud 的结束会话端点
frontendURL := os.Getenv("FRONTEND_URL")
endSessionBase := os.Getenv("CBC_ISSUER") + "/oidc/end-session"
u, _ := url.Parse(endSessionBase)
q := u.Query()
q.Set("id_token_hint", idToken)
q.Set("post_logout_redirect_uri", frontendURL)
u.RawQuery = q.Encode()
http.Redirect(c.Writer, c.Request, u.String(), http.StatusFound)
}
// revokeToken 吊销令牌
func (a *OIDCAuth) revokeToken(token, tokenType string) {
issuer := os.Getenv("CBC_ISSUER")
body := "token=" + token + "&token_type_hint=" + tokenType
req, err := http.NewRequest("POST", issuer+"/oidc/revoke", strings.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
http.DefaultClient.Do(req)
}
// HandleMe 返回当前登录用户信息
func (a *OIDCAuth) HandleMe(c *gin.Context) {
session, _ := a.Store.Get(c.Request, sessionName)
userInfoStr, ok := session.Values["user_info"].(string)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
var userInfo UserInfo
if err := json.Unmarshal([]byte(userInfoStr), &userInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user data"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": userInfo,
})
}
// HandleRefreshToken 刷新 Access Token
func (a *OIDCAuth) HandleRefreshToken(c *gin.Context) {
ctx := c.Request.Context()
session, _ := a.Store.Get(c.Request, sessionName)
refreshToken, ok := session.Values["refresh_token"].(string)
if !ok || refreshToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "no refresh token"})
return
}
// 使用 Refresh Token 获取新令牌
tokenSource := a.OAuth2Config.TokenSource(ctx, &oauth2.Token{
RefreshToken: refreshToken,
})
newToken, err := tokenSource.Token()
if err != nil {
// Refresh Token 失效,需要重新登录
session.Options.MaxAge = -1
session.Save(c.Request, c.Writer)
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh failed, please login again"})
return
}
// 更新 Session 中的令牌
session.Values["access_token"] = newToken.AccessToken
if newToken.RefreshToken != "" {
session.Values["refresh_token"] = newToken.RefreshToken
}
session.Values["token_expiry"] = newToken.Expiry.Unix()
if rawIDToken, ok := newToken.Extra("id_token").(string); ok {
session.Values["id_token"] = rawIDToken
}
session.Save(c.Request, c.Writer)
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "token refreshed"})
}2.6 认证中间件
go
// internal/auth/middleware.go
package auth
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
)
// RequireAuth 保护需要登录的路由
func (a *OIDCAuth) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
session, _ := a.Store.Get(c.Request, sessionName)
// 检查是否有用户信息
if _, ok := session.Values["user_info"]; !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "not authenticated",
})
return
}
// 检查 token 是否即将过期(提前 5 分钟刷新)
if expiry, ok := session.Values["token_expiry"].(int64); ok {
if time.Now().Unix() > expiry-300 {
// 尝试自动刷新
a.tryRefreshToken(c, session)
}
}
// 将 Access Token 注入上下文,供业务 API 使用
if accessToken, ok := session.Values["access_token"].(string); ok {
c.Set("access_token", accessToken)
}
if userInfo, ok := session.Values["user_info"].(string); ok {
c.Set("user_info", userInfo)
}
c.Next()
}
}
func (a *OIDCAuth) tryRefreshToken(c *gin.Context, session *sessions.Session) {
refreshToken, ok := session.Values["refresh_token"].(string)
if !ok || refreshToken == "" {
return
}
tokenSource := a.OAuth2Config.TokenSource(c.Request.Context(), &oauth2.Token{
RefreshToken: refreshToken,
})
newToken, err := tokenSource.Token()
if err != nil {
return
}
session.Values["access_token"] = newToken.AccessToken
if newToken.RefreshToken != "" {
session.Values["refresh_token"] = newToken.RefreshToken
}
session.Values["token_expiry"] = newToken.Expiry.Unix()
session.Save(c.Request, c.Writer)
}2.7 M2M Token 管理(可选)
如果需要通过 Management API 管理用户:
go
// internal/auth/m2m.go
package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
type M2MTokenManager struct {
issuer string
clientID string
clientSecret string
token string
expiresAt time.Time
mu sync.RWMutex
}
func NewM2MTokenManager() *M2MTokenManager {
return &M2MTokenManager{
issuer: os.Getenv("CBC_ISSUER"),
clientID: os.Getenv("CBC_M2M_CLIENT_ID"),
clientSecret: os.Getenv("CBC_M2M_CLIENT_SECRET"),
}
}
// GetToken 获取有效的 M2M Access Token,自动缓存和刷新
func (m *M2MTokenManager) GetToken() (string, error) {
m.mu.RLock()
if m.token != "" && time.Now().Before(m.expiresAt.Add(-5*time.Minute)) {
defer m.mu.RUnlock()
return m.token, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring write lock
if m.token != "" && time.Now().Before(m.expiresAt.Add(-5*time.Minute)) {
return m.token, nil
}
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {m.clientID},
"client_secret": {m.clientSecret},
"scope": {"openid"},
}
resp, err := http.Post(
m.issuer+"/oidc/token",
"application/x-www-form-urlencoded",
strings.NewReader(data.Encode()),
)
if err != nil {
return "", fmt.Errorf("failed to request M2M token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("M2M token request failed with status: %d", resp.StatusCode)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode M2M token response: %w", err)
}
m.token = result.AccessToken
m.expiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
return m.token, nil
}
// CallManagementAPI 调用 Code Bird Cloud Management API
func (m *M2MTokenManager) CallManagementAPI(method, path string, body any) (*http.Response, error) {
token, err := m.GetToken()
if err != nil {
return nil, err
}
var reqBody *strings.Reader
if body != nil {
bodyJSON, _ := json.Marshal(body)
reqBody = strings.NewReader(string(bodyJSON))
} else {
reqBody = strings.NewReader("")
}
req, err := http.NewRequest(method, m.issuer+path, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return http.DefaultClient.Do(req)
}2.8 主入口文件
go
// main.go
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"your-project/internal/auth"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
godotenv.Load()
ctx := context.Background()
// 初始化 OIDC 认证
oidcAuth, err := auth.NewOIDCAuth(ctx)
if err != nil {
log.Fatalf("Failed to init OIDC: %v", err)
}
// 初始化 M2M token 管理器(可选)
m2m := auth.NewM2MTokenManager()
r := gin.Default()
// CORS 配置
frontendURL := os.Getenv("FRONTEND_URL")
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", frontendURL)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
})
// ===== 认证路由(无需登录) =====
authGroup := r.Group("/auth")
{
authGroup.GET("/login", oidcAuth.HandleLogin)
authGroup.GET("/callback", oidcAuth.HandleCallback)
authGroup.GET("/logout", oidcAuth.HandleLogout)
}
// ===== 受保护 API(需要登录) =====
api := r.Group("/api", oidcAuth.RequireAuth())
{
// 获取当前用户信息
api.GET("/me", oidcAuth.HandleMe)
// 刷新 Token
api.POST("/refresh", oidcAuth.HandleRefreshToken)
// ========== 你的业务 API ==========
api.GET("/dashboard", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": gin.H{"message": "Welcome to your dashboard"},
})
})
// 示例:通过 M2M 查询用户列表(管理功能)
api.GET("/admin/users", func(c *gin.Context) {
resp, err := m2m.CallManagementAPI("GET", "/api/v1/users?page=1&page_size=20", nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
c.JSON(http.StatusOK, result)
})
}
log.Println("Server running at http://localhost:8080")
r.Run(":8080")
}第 3 步:React 前端实现
3.1 项目结构
your-project-frontend/
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/
│ │ └── client.ts # API 请求封装
│ ├── hooks/
│ │ └── useAuth.ts # 认证状态管理
│ ├── components/
│ │ ├── ProtectedRoute.tsx # 路由守卫
│ │ └── UserMenu.tsx # 用户菜单
│ └── pages/
│ ├── Home.tsx
│ └── Dashboard.tsx
├── package.json
├── vite.config.ts
└── .env3.2 环境变量
bash
# .env
VITE_API_BASE_URL=http://localhost:80803.3 API 请求封装
typescript
// src/api/client.ts
const API_BASE = import.meta.env.VITE_API_BASE_URL;
interface ApiResponse<T = unknown> {
code: number;
data: T;
message?: string;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...options,
credentials: 'include', // 携带 Cookie(Session)
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
// 未认证,跳转登录
if (response.status === 401) {
window.location.href = `${API_BASE}/auth/login`;
throw new Error('Not authenticated');
}
const result: ApiResponse<T> = await response.json();
if (result.code !== 0) {
throw new Error(result.message || 'Request failed');
}
return result.data;
}
export const api = {
// 获取当前用户
getMe: () => request<UserInfo>('/api/me'),
// 刷新 Token
refreshToken: () =>
fetch(`${API_BASE}/api/refresh`, {
method: 'POST',
credentials: 'include',
}),
// 登录地址(重定向)
loginUrl: `${API_BASE}/auth/login`,
// 登出地址(重定向)
logoutUrl: `${API_BASE}/auth/logout`,
};
export interface UserInfo {
sub: string;
name: string;
username: string;
email: string;
picture?: string;
gender?: number;
phone_number?: string;
updated_at?: number;
organizations?: string[];
organization_roles?: string[]; // 格式: ["org_id:role_name", ...]
}3.4 认证状态管理
typescript
// src/hooks/useAuth.ts
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
import { api, type UserInfo } from '../api/client';
interface AuthState {
user: UserInfo | null;
loading: boolean;
error: string | null;
login: () => void;
logout: () => void;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 初始化时获取用户信息
useEffect(() => {
api
.getMe()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const login = useCallback(() => {
window.location.href = api.loginUrl;
}, []);
const logout = useCallback(() => {
window.location.href = api.logoutUrl;
}, []);
const refresh = useCallback(async () => {
await api.refreshToken();
const me = await api.getMe();
setUser(me);
}, []);
return (
<AuthContext.Provider value={{ user, loading, error, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within AuthProvider');
}
return ctx;
}3.5 路由守卫
tsx
// src/components/ProtectedRoute.tsx
import { useAuth } from '../hooks/useAuth';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading, login } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
login();
return null;
}
return <>{children}</>;
}3.6 用户菜单组件
tsx
// src/components/UserMenu.tsx
import { useAuth } from '../hooks/useAuth';
export function UserMenu() {
const { user, logout } = useAuth();
if (!user) return null;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{user.picture && (
<img
src={user.picture}
alt={user.name}
style={{ width: 32, height: 32, borderRadius: '50%' }}
/>
)}
<span>{user.name || user.email}</span>
<button onClick={logout}>Logout</button>
</div>
);
}3.7 页面和路由
tsx
// src/App.tsx
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { ProtectedRoute } from './components/ProtectedRoute';
import { UserMenu } from './components/UserMenu';
function Home() {
const { user, login } = useAuth();
return (
<div>
<h1>Welcome</h1>
{user ? (
<p>
Logged in as {user.name}. <Link to="/dashboard">Go to Dashboard</Link>
</p>
) : (
<button onClick={login}>Login with Code Bird Cloud</button>
)}
</div>
);
}
function Dashboard() {
const { user } = useAuth();
return (
<div>
<h1>Dashboard</h1>
<pre>{JSON.stringify(user, null, 2)}</pre>
</div>
);
}
function Layout() {
return (
<div>
<nav style={{ display: 'flex', justifyContent: 'space-between', padding: 16 }}>
<Link to="/">Home</Link>
<UserMenu />
</nav>
<main style={{ padding: 16 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</main>
</div>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Layout />
</AuthProvider>
</BrowserRouter>
);
}3.8 入口文件
tsx
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);3.9 Vite 开发代理(可选)
如果不想处理跨域,可以在开发环境配置代理:
typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/auth': 'http://localhost:8080',
'/api': 'http://localhost:8080',
},
},
});使用代理后,前端请求地址改为相对路径,.env 中 VITE_API_BASE_URL 留空即可。
第 4 步:运行和测试
4.1 启动 Go 后端
bash
cd your-project-backend
go run main.go
# Server running at http://localhost:80804.2 启动 React 前端
bash
cd your-project-frontend
pnpm install
pnpm dev
# Local: http://localhost:51734.3 完整流程验证
- 访问
http://localhost:5173 - 点击 "Login with Code Bird Cloud"
- 浏览器跳转到 Go 后端
/auth/login - Go 后端重定向到 Code Bird Cloud 登录页
- 输入用户名和密码完成登录
- Code Bird Cloud 回调到 Go 后端
/auth/callback - Go 后端完成令牌交换,重定向回前端
- 前端自动获取用户信息并显示
- 访问 Dashboard 页面验证路由守卫
- 点击 Logout 完成登出
安全注意事项
- Session Secret:生产环境必须使用高强度随机字符串,通过环境变量注入
- HTTPS:生产环境所有通信必须使用 HTTPS,Cookie 设置
Secure: true - CORS:生产环境的
Access-Control-Allow-Origin必须设为你的前端域名,不要使用* - Client Secret:绝不能暴露在前端代码或版本控制中
- Cookie 安全:
HttpOnly: true-- 防止 JS 读取 CookieSameSite: Lax-- 防止 CSRFSecure: true-- 生产环境强制 HTTPS
- State 参数:必须使用
crypto/rand生成,并设置过期时间
常见问题
OIDC Provider 初始化失败
Failed to init OIDC provider: Get "https://your-cbc.com/.well-known/openid-configuration": dial tcp: connection refused确认 Code Bird Cloud 服务正在运行且地址正确。如果使用自签名证书,需要配置 HTTP Client 信任该证书。
回调报 "invalid state parameter"
- 确认浏览器允许 Cookie(Session 依赖 Cookie 传递 state)
- 检查前后端的域名和端口是否一致
- 确认 Cookie 的
SameSite设置与跨域场景匹配
Token 刷新后组织信息丢失
刷新令牌时如需获取组织范围的 Access Token,需要在刷新请求中传递 organization_id 参数。参阅 M2M 组织级 Token。
前端收到 401 但已登录
- Session 可能已过期(默认 24 小时),需要重新登录
- 检查 Cookie 是否被浏览器拦截(跨域场景需要
credentials: 'include') - 检查 Go 后端 CORS 配置是否包含
Access-Control-Allow-Credentials: true
相关文档
- Authorization Code Flow 参考 -- 流程详解和参数说明
- Go 集成教程 -- Go 单独集成的简化版本
- M2M 认证指南 -- 后端服务间通信
- Management API 交互 -- 通过 M2M 管理平台资源
- 应用数据结构 -- 应用类型和配置字段说明
- 集成概览 -- 所有集成方式的总览
