// main.go package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "log" "net/http" "os" "strconv" "time" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) var ( ctx = context.Background() rdb *redis.Client // 从 env 加载 clientID = os.Getenv("CASDOOR_CLIENT_ID") clientSecret = os.Getenv("CASDOOR_CLIENT_SECRET") authURL = os.Getenv("CASDOOR_AUTHORIZE_URL") // e.g. https://door.example.com/login/oauth/authorize tokenURL = os.Getenv("CASDOOR_TOKEN_URL") // e.g. https://door.example.com/api/login/oauth/access_token redirectURI = os.Getenv("APP_REDIRECT_URI") // e.g. https://api.example.com/api/auth/callback frontendURL = os.Getenv("FRONTEND_URL") // e.g. https://app.example.com // session cookie name sessionCookieName = "sid" // session TTL (sync with refresh token validity; e.g. 7 days) sessionTTL = 7 * 24 * time.Hour ) type OAuthTokenResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` // 秒 // 可能还有其他字段,按实际返回解析 } /* ------------------- Redis helpers ------------------- */ func initRedis() { rdb = redis.NewClient(&redis.Options{ Addr: "127.0.0.1:6379", // env 改造 Password: "", DB: 0, }) if _, err := rdb.Ping(ctx).Result(); err != nil { log.Fatalf("redis connect error: %v", err) } } func storeSession(sid string, token OAuthTokenResp) error { key := fmt.Sprintf("session:%s", sid) exp := time.Now().Add(sessionTTL) accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix() fields := map[string]interface{}{ "refresh_token": token.RefreshToken, "access_token": token.AccessToken, "access_exp": accessExp, } if err := rdb.HSet(ctx, key, fields).Err(); err != nil { return err } return rdb.ExpireAt(ctx, key, exp).Err() } func getSession(sid string) (map[string]string, error) { key := fmt.Sprintf("session:%s", sid) res, err := rdb.HGetAll(ctx, key).Result() if err != nil { return nil, err } if len(res) == 0 { return nil, errors.New("no session") } return res, nil } func deleteSession(sid string) error { key := fmt.Sprintf("session:%s", sid) return rdb.Del(ctx, key).Err() } /* ------------------- Casdoor interaction helpers ------------------- */ // 标准 OAuth2 token exchange func exchangeCodeForToken(code string) (OAuthTokenResp, error) { form := map[string]string{ "grant_type": "authorization_code", "code": code, "redirect_uri": redirectURI, "client_id": clientID, "client_secret": clientSecret, } return postTokenForm(form) } func refreshWithRefreshToken(refreshToken string) (OAuthTokenResp, error) { form := map[string]string{ "grant_type": "refresh_token", "refresh_token": refreshToken, "client_id": clientID, "client_secret": clientSecret, } return postTokenForm(form) } func postTokenForm(form map[string]string) (OAuthTokenResp, error) { bodyBytes, _ := json.Marshal(form) req, _ := http.NewRequest("POST", tokenURL, bytes.NewBuffer(bodyBytes)) req.Header.Set("Content-Type", "application/json") // 某些 Casdoor 期望 application/x-www-form-urlencoded:根据 Casdoor 实际调整 client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return OAuthTokenResp{}, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return OAuthTokenResp{}, fmt.Errorf("token endpoint returned %d", resp.StatusCode) } var tr OAuthTokenResp if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return OAuthTokenResp{}, err } return tr, nil } /* ------------------- Handlers ------------------- */ // GET /api/auth/login -> 重定向到 Casdoor authorize URL func loginRedirect(c *fiber.Ctx) error { // state 可用于防 CSRF state := uuid.NewString() // 保存 state -> 可以放到 Redis 验证,不过此处省略 u := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&state=%s", authURL, clientID, redirectURI, state) return c.Redirect(u, fiber.StatusFound) } // GET /api/auth/callback?code=...&state=... func callbackHandler(c *fiber.Ctx) error { code := c.Query("code") if code == "" { return c.Status(400).SendString("missing code") } // 用 code 换 token tokenResp, err := exchangeCodeForToken(code) if err != nil { log.Println("exchange code err:", err) return c.Status(500).JSON(fiber.Map{"error": "token exchange failed"}) } // 生成 sid 并存 Redis(只存 refresh_token,access_token 可以缓存) sid := uuid.NewString() if err := storeSession(sid, tokenResp); err != nil { log.Println("store session err:", err) } // 设置 HttpOnly cookie 保存 sid c.Cookie(&fiber.Cookie{ Name: sessionCookieName, Value: sid, Expires: time.Now().Add(sessionTTL), HTTPOnly: true, Secure: false, // production: true SameSite: "Lax", Path: "/", }) // 最后重定向回前端单页 app return c.Redirect(frontendURL, fiber.StatusFound) } // GET /api/auth/session -> 前端载入时调用,返回 access_token(若存在) func sessionHandler(c *fiber.Ctx) error { sid := c.Cookies(sessionCookieName) if sid == "" { return c.Status(200).JSON(fiber.Map{"authenticated": false}) } sess, err := getSession(sid) if err != nil { // session 不存在或已过期 // 清除 cookie c.Cookie(&fiber.Cookie{ Name: sessionCookieName, Value: "", Expires: time.Now().Add(-time.Hour), HTTPOnly: true, Secure: false, Path: "/", }) return c.Status(200).JSON(fiber.Map{"authenticated": false}) } // 若 access_token 仍有效则直接返回 access := sess["access_token"] accessExpStr := sess["access_exp"] if access != "" && accessExpStr != "" { accessExp, _ := strconv.ParseInt(accessExpStr, 10, 64) if time.Now().Unix() < accessExp { return c.JSON(fiber.Map{"authenticated": true, "access_token": access}) } } // access 已过期 -> 试着用 refresh token 刷新(后端直接刷新并返回) refreshToken := sess["refresh_token"] if refreshToken == "" { return c.Status(200).JSON(fiber.Map{"authenticated": false}) } newTok, err := refreshWithRefreshToken(refreshToken) if err != nil { // refresh 失败 -> session 失效 _ = deleteSession(sid) c.Cookie(&fiber.Cookie{ Name: sessionCookieName, Value: "", Expires: time.Now().Add(-time.Hour), HTTPOnly: true, Secure: false, Path: "/", }) return c.Status(200).JSON(fiber.Map{"authenticated": false}) } // 更新 session(rotate) if err := storeSession(sid, newTok); err != nil { log.Println("update session err:", err) } return c.JSON(fiber.Map{"authenticated": true, "access_token": newTok.AccessToken}) } // POST /api/auth/refresh -> 前端拦截器触发(带 sid cookie),后端用存在 Redis 的 refresh_token 调 Casdoor 刷新 func refreshHandler(c *fiber.Ctx) error { sid := c.Cookies(sessionCookieName) if sid == "" { return c.Status(401).JSON(fiber.Map{"error": "no session"}) } sess, err := getSession(sid) if err != nil { return c.Status(401).JSON(fiber.Map{"error": "session not found"}) } rt := sess["refresh_token"] if rt == "" { return c.Status(401).JSON(fiber.Map{"error": "no refresh token"}) } newTok, err := refreshWithRefreshToken(rt) if err != nil { // refresh token 也过期或无效 _ = deleteSession(sid) c.Cookie(&fiber.Cookie{ Name: sessionCookieName, Value: "", Expires: time.Now().Add(-time.Hour), HTTPOnly: true, Secure: false, Path: "/", }) return c.Status(401).JSON(fiber.Map{"error": "refresh failed"}) } // rotate: 更新 Redis 存储(用同 sid 存新 refresh token + new access) if err := storeSession(sid, newTok); err != nil { log.Println("store session after refresh err:", err) } // 返回新的 access token 给前端(前端把它放内存) return c.JSON(fiber.Map{ "access_token": newTok.AccessToken, "expires_in": newTok.ExpiresIn, }) } // POST /api/auth/logout func logoutHandler(c *fiber.Ctx) error { sid := c.Cookies(sessionCookieName) if sid != "" { _ = deleteSession(sid) } // 清除 cookie c.Cookie(&fiber.Cookie{ Name: sessionCookieName, Value: "", Expires: time.Now().Add(-time.Hour), HTTPOnly: true, Secure: false, Path: "/", }) return c.JSON(fiber.Map{"ok": true}) } /* ------------------- main ------------------- */ func main() { // 检查必需 env if clientID == "" || clientSecret == "" || authURL == "" || tokenURL == "" || redirectURI == "" || frontendURL == "" { log.Println("Please set CASDOOR_CLIENT_ID, CASDOOR_CLIENT_SECRET, CASDOOR_AUTHORIZE_URL, CASDOOR_TOKEN_URL, APP_REDIRECT_URI, FRONTEND_URL") // 继续运行以便开发测试,但最好中断 } initRedis() app := fiber.New() app.Get("/api/auth/login", loginRedirect) app.Get("/api/auth/callback", callbackHandler) app.Get("/api/auth/session", sessionHandler) app.Post("/api/auth/refresh", refreshHandler) app.Post("/api/auth/logout", logoutHandler) // example protected endpoint: 前端带 access_token in Authorization header app.Get("/api/me", func(c *fiber.Ctx) error { auth := c.Get("Authorization") if auth == "" { return c.Status(401).JSON(fiber.Map{"error": "missing token"}) } // 这里可以验证 Casdoor 的 access token 或调用 Casdoor 的 userinfo endpoint // 为示例我们只是回显(生产需校验) return c.JSON(fiber.Map{"token": auth}) }) log.Fatal(app.Listen(":3000")) }