| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- // 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"))
- }
|