Untitled-1 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. // main.go
  2. package main
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "log"
  10. "net/http"
  11. "os"
  12. "strconv"
  13. "time"
  14. "github.com/gofiber/fiber/v2"
  15. "github.com/google/uuid"
  16. "github.com/redis/go-redis/v9"
  17. )
  18. var (
  19. ctx = context.Background()
  20. rdb *redis.Client
  21. // 从 env 加载
  22. clientID = os.Getenv("CASDOOR_CLIENT_ID")
  23. clientSecret = os.Getenv("CASDOOR_CLIENT_SECRET")
  24. authURL = os.Getenv("CASDOOR_AUTHORIZE_URL") // e.g. https://door.example.com/login/oauth/authorize
  25. tokenURL = os.Getenv("CASDOOR_TOKEN_URL") // e.g. https://door.example.com/api/login/oauth/access_token
  26. redirectURI = os.Getenv("APP_REDIRECT_URI") // e.g. https://api.example.com/api/auth/callback
  27. frontendURL = os.Getenv("FRONTEND_URL") // e.g. https://app.example.com
  28. // session cookie name
  29. sessionCookieName = "sid"
  30. // session TTL (sync with refresh token validity; e.g. 7 days)
  31. sessionTTL = 7 * 24 * time.Hour
  32. )
  33. type OAuthTokenResp struct {
  34. AccessToken string `json:"access_token"`
  35. RefreshToken string `json:"refresh_token"`
  36. ExpiresIn int `json:"expires_in"` // 秒
  37. // 可能还有其他字段,按实际返回解析
  38. }
  39. /* ------------------- Redis helpers ------------------- */
  40. func initRedis() {
  41. rdb = redis.NewClient(&redis.Options{
  42. Addr: "127.0.0.1:6379", // env 改造
  43. Password: "",
  44. DB: 0,
  45. })
  46. if _, err := rdb.Ping(ctx).Result(); err != nil {
  47. log.Fatalf("redis connect error: %v", err)
  48. }
  49. }
  50. func storeSession(sid string, token OAuthTokenResp) error {
  51. key := fmt.Sprintf("session:%s", sid)
  52. exp := time.Now().Add(sessionTTL)
  53. accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
  54. fields := map[string]interface{}{
  55. "refresh_token": token.RefreshToken,
  56. "access_token": token.AccessToken,
  57. "access_exp": accessExp,
  58. }
  59. if err := rdb.HSet(ctx, key, fields).Err(); err != nil {
  60. return err
  61. }
  62. return rdb.ExpireAt(ctx, key, exp).Err()
  63. }
  64. func getSession(sid string) (map[string]string, error) {
  65. key := fmt.Sprintf("session:%s", sid)
  66. res, err := rdb.HGetAll(ctx, key).Result()
  67. if err != nil {
  68. return nil, err
  69. }
  70. if len(res) == 0 {
  71. return nil, errors.New("no session")
  72. }
  73. return res, nil
  74. }
  75. func deleteSession(sid string) error {
  76. key := fmt.Sprintf("session:%s", sid)
  77. return rdb.Del(ctx, key).Err()
  78. }
  79. /* ------------------- Casdoor interaction helpers ------------------- */
  80. // 标准 OAuth2 token exchange
  81. func exchangeCodeForToken(code string) (OAuthTokenResp, error) {
  82. form := map[string]string{
  83. "grant_type": "authorization_code",
  84. "code": code,
  85. "redirect_uri": redirectURI,
  86. "client_id": clientID,
  87. "client_secret": clientSecret,
  88. }
  89. return postTokenForm(form)
  90. }
  91. func refreshWithRefreshToken(refreshToken string) (OAuthTokenResp, error) {
  92. form := map[string]string{
  93. "grant_type": "refresh_token",
  94. "refresh_token": refreshToken,
  95. "client_id": clientID,
  96. "client_secret": clientSecret,
  97. }
  98. return postTokenForm(form)
  99. }
  100. func postTokenForm(form map[string]string) (OAuthTokenResp, error) {
  101. bodyBytes, _ := json.Marshal(form)
  102. req, _ := http.NewRequest("POST", tokenURL, bytes.NewBuffer(bodyBytes))
  103. req.Header.Set("Content-Type", "application/json") // 某些 Casdoor 期望 application/x-www-form-urlencoded:根据 Casdoor 实际调整
  104. client := &http.Client{Timeout: 10 * time.Second}
  105. resp, err := client.Do(req)
  106. if err != nil {
  107. return OAuthTokenResp{}, err
  108. }
  109. defer resp.Body.Close()
  110. if resp.StatusCode >= 400 {
  111. return OAuthTokenResp{}, fmt.Errorf("token endpoint returned %d", resp.StatusCode)
  112. }
  113. var tr OAuthTokenResp
  114. if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
  115. return OAuthTokenResp{}, err
  116. }
  117. return tr, nil
  118. }
  119. /* ------------------- Handlers ------------------- */
  120. // GET /api/auth/login -> 重定向到 Casdoor authorize URL
  121. func loginRedirect(c *fiber.Ctx) error {
  122. // state 可用于防 CSRF
  123. state := uuid.NewString()
  124. // 保存 state -> 可以放到 Redis 验证,不过此处省略
  125. u := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&state=%s", authURL, clientID, redirectURI, state)
  126. return c.Redirect(u, fiber.StatusFound)
  127. }
  128. // GET /api/auth/callback?code=...&state=...
  129. func callbackHandler(c *fiber.Ctx) error {
  130. code := c.Query("code")
  131. if code == "" {
  132. return c.Status(400).SendString("missing code")
  133. }
  134. // 用 code 换 token
  135. tokenResp, err := exchangeCodeForToken(code)
  136. if err != nil {
  137. log.Println("exchange code err:", err)
  138. return c.Status(500).JSON(fiber.Map{"error": "token exchange failed"})
  139. }
  140. // 生成 sid 并存 Redis(只存 refresh_token,access_token 可以缓存)
  141. sid := uuid.NewString()
  142. if err := storeSession(sid, tokenResp); err != nil {
  143. log.Println("store session err:", err)
  144. }
  145. // 设置 HttpOnly cookie 保存 sid
  146. c.Cookie(&fiber.Cookie{
  147. Name: sessionCookieName,
  148. Value: sid,
  149. Expires: time.Now().Add(sessionTTL),
  150. HTTPOnly: true,
  151. Secure: false, // production: true
  152. SameSite: "Lax",
  153. Path: "/",
  154. })
  155. // 最后重定向回前端单页 app
  156. return c.Redirect(frontendURL, fiber.StatusFound)
  157. }
  158. // GET /api/auth/session -> 前端载入时调用,返回 access_token(若存在)
  159. func sessionHandler(c *fiber.Ctx) error {
  160. sid := c.Cookies(sessionCookieName)
  161. if sid == "" {
  162. return c.Status(200).JSON(fiber.Map{"authenticated": false})
  163. }
  164. sess, err := getSession(sid)
  165. if err != nil {
  166. // session 不存在或已过期
  167. // 清除 cookie
  168. c.Cookie(&fiber.Cookie{
  169. Name: sessionCookieName,
  170. Value: "",
  171. Expires: time.Now().Add(-time.Hour),
  172. HTTPOnly: true,
  173. Secure: false,
  174. Path: "/",
  175. })
  176. return c.Status(200).JSON(fiber.Map{"authenticated": false})
  177. }
  178. // 若 access_token 仍有效则直接返回
  179. access := sess["access_token"]
  180. accessExpStr := sess["access_exp"]
  181. if access != "" && accessExpStr != "" {
  182. accessExp, _ := strconv.ParseInt(accessExpStr, 10, 64)
  183. if time.Now().Unix() < accessExp {
  184. return c.JSON(fiber.Map{"authenticated": true, "access_token": access})
  185. }
  186. }
  187. // access 已过期 -> 试着用 refresh token 刷新(后端直接刷新并返回)
  188. refreshToken := sess["refresh_token"]
  189. if refreshToken == "" {
  190. return c.Status(200).JSON(fiber.Map{"authenticated": false})
  191. }
  192. newTok, err := refreshWithRefreshToken(refreshToken)
  193. if err != nil {
  194. // refresh 失败 -> session 失效
  195. _ = deleteSession(sid)
  196. c.Cookie(&fiber.Cookie{
  197. Name: sessionCookieName,
  198. Value: "",
  199. Expires: time.Now().Add(-time.Hour),
  200. HTTPOnly: true,
  201. Secure: false,
  202. Path: "/",
  203. })
  204. return c.Status(200).JSON(fiber.Map{"authenticated": false})
  205. }
  206. // 更新 session(rotate)
  207. if err := storeSession(sid, newTok); err != nil {
  208. log.Println("update session err:", err)
  209. }
  210. return c.JSON(fiber.Map{"authenticated": true, "access_token": newTok.AccessToken})
  211. }
  212. // POST /api/auth/refresh -> 前端拦截器触发(带 sid cookie),后端用存在 Redis 的 refresh_token 调 Casdoor 刷新
  213. func refreshHandler(c *fiber.Ctx) error {
  214. sid := c.Cookies(sessionCookieName)
  215. if sid == "" {
  216. return c.Status(401).JSON(fiber.Map{"error": "no session"})
  217. }
  218. sess, err := getSession(sid)
  219. if err != nil {
  220. return c.Status(401).JSON(fiber.Map{"error": "session not found"})
  221. }
  222. rt := sess["refresh_token"]
  223. if rt == "" {
  224. return c.Status(401).JSON(fiber.Map{"error": "no refresh token"})
  225. }
  226. newTok, err := refreshWithRefreshToken(rt)
  227. if err != nil {
  228. // refresh token 也过期或无效
  229. _ = deleteSession(sid)
  230. c.Cookie(&fiber.Cookie{
  231. Name: sessionCookieName,
  232. Value: "",
  233. Expires: time.Now().Add(-time.Hour),
  234. HTTPOnly: true,
  235. Secure: false,
  236. Path: "/",
  237. })
  238. return c.Status(401).JSON(fiber.Map{"error": "refresh failed"})
  239. }
  240. // rotate: 更新 Redis 存储(用同 sid 存新 refresh token + new access)
  241. if err := storeSession(sid, newTok); err != nil {
  242. log.Println("store session after refresh err:", err)
  243. }
  244. // 返回新的 access token 给前端(前端把它放内存)
  245. return c.JSON(fiber.Map{
  246. "access_token": newTok.AccessToken,
  247. "expires_in": newTok.ExpiresIn,
  248. })
  249. }
  250. // POST /api/auth/logout
  251. func logoutHandler(c *fiber.Ctx) error {
  252. sid := c.Cookies(sessionCookieName)
  253. if sid != "" {
  254. _ = deleteSession(sid)
  255. }
  256. // 清除 cookie
  257. c.Cookie(&fiber.Cookie{
  258. Name: sessionCookieName,
  259. Value: "",
  260. Expires: time.Now().Add(-time.Hour),
  261. HTTPOnly: true,
  262. Secure: false,
  263. Path: "/",
  264. })
  265. return c.JSON(fiber.Map{"ok": true})
  266. }
  267. /* ------------------- main ------------------- */
  268. func main() {
  269. // 检查必需 env
  270. if clientID == "" || clientSecret == "" || authURL == "" || tokenURL == "" || redirectURI == "" || frontendURL == "" {
  271. log.Println("Please set CASDOOR_CLIENT_ID, CASDOOR_CLIENT_SECRET, CASDOOR_AUTHORIZE_URL, CASDOOR_TOKEN_URL, APP_REDIRECT_URI, FRONTEND_URL")
  272. // 继续运行以便开发测试,但最好中断
  273. }
  274. initRedis()
  275. app := fiber.New()
  276. app.Get("/api/auth/login", loginRedirect)
  277. app.Get("/api/auth/callback", callbackHandler)
  278. app.Get("/api/auth/session", sessionHandler)
  279. app.Post("/api/auth/refresh", refreshHandler)
  280. app.Post("/api/auth/logout", logoutHandler)
  281. // example protected endpoint: 前端带 access_token in Authorization header
  282. app.Get("/api/me", func(c *fiber.Ctx) error {
  283. auth := c.Get("Authorization")
  284. if auth == "" {
  285. return c.Status(401).JSON(fiber.Map{"error": "missing token"})
  286. }
  287. // 这里可以验证 Casdoor 的 access token 或调用 Casdoor 的 userinfo endpoint
  288. // 为示例我们只是回显(生产需校验)
  289. return c.JSON(fiber.Map{"token": auth})
  290. })
  291. log.Fatal(app.Listen(":3000"))
  292. }