Skip to content

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 URIhttp://localhost:8080/auth/callback
Post-logout Redirect URIhttp://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
└── .env

2.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/godotenv

2.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_secret

2.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
└── .env

3.2 环境变量

bash
# .env
VITE_API_BASE_URL=http://localhost:8080

3.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',
    },
  },
});

使用代理后,前端请求地址改为相对路径,.envVITE_API_BASE_URL 留空即可。


第 4 步:运行和测试

4.1 启动 Go 后端

bash
cd your-project-backend
go run main.go
# Server running at http://localhost:8080

4.2 启动 React 前端

bash
cd your-project-frontend
pnpm install
pnpm dev
# Local: http://localhost:5173

4.3 完整流程验证

  1. 访问 http://localhost:5173
  2. 点击 "Login with Code Bird Cloud"
  3. 浏览器跳转到 Go 后端 /auth/login
  4. Go 后端重定向到 Code Bird Cloud 登录页
  5. 输入用户名和密码完成登录
  6. Code Bird Cloud 回调到 Go 后端 /auth/callback
  7. Go 后端完成令牌交换,重定向回前端
  8. 前端自动获取用户信息并显示
  9. 访问 Dashboard 页面验证路由守卫
  10. 点击 Logout 完成登出

安全注意事项

  1. Session Secret:生产环境必须使用高强度随机字符串,通过环境变量注入
  2. HTTPS:生产环境所有通信必须使用 HTTPS,Cookie 设置 Secure: true
  3. CORS:生产环境的 Access-Control-Allow-Origin 必须设为你的前端域名,不要使用 *
  4. Client Secret:绝不能暴露在前端代码或版本控制中
  5. Cookie 安全
    • HttpOnly: true -- 防止 JS 读取 Cookie
    • SameSite: Lax -- 防止 CSRF
    • Secure: true -- 生产环境强制 HTTPS
  6. 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

相关文档

Released under the MIT License.