From 9ca573aa3cf611a64e6752ee3d78fa0f7ecb55f1 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 25 Aug 2025 00:54:59 +0800 Subject: [PATCH 1/4] feat(gauth): allow multiple redirect URIs --- docs/config.md | 4 +-- httpapi/auth/gauth.go | 63 ++++++++++++++++++++++++++++++++++----- httpapi/auth/root.go | 2 +- internal/config/models.go | 10 +++---- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5bcbba5..10e3492 100644 --- a/docs/config.md +++ b/docs/config.md @@ -46,8 +46,8 @@ Database Playground 使用 PostgreSQL 作為資料庫。 - `GAUTH_CLIENT_ID`:Google OAuth 的 Client ID - `GAUTH_CLIENT_SECRET`:Google OAuth 的 Client Secret -- `GAUTH_REDIRECT_URI`:在完成 Google OAuth 流程後,要重新導向到的 URL,通常是指向前端。 - - 舉例:你的前端會在進入起始連結前,記錄目前頁面的位址,然後在 `/auth/completed` endpoint 重新導向回使用者上次瀏覽的連結。這時,你可以將重新導向連結寫為 `https://app.yourdomain.tld/auth/completed`。如果你沒有這樣的 endpoint,寫上前端的首頁也是可以的。注意在起始連結帶入的 `state` 會被帶入這個 URI 中。 +- `GAUTH_REDIRECT_URIS`:在完成 Google OAuth 流程後,允許重新導向到的 URIs。 + - 舉例:`https://admin.dbplay.app` Google OAuth 的登入起始連結為 `https://backend.yourdomain.tld/api/auth/google/login`,可選擇性帶入 `state` 參數。 Google OAuth 的回呼連結為 `https://backend.yourdomain.tld/api/auth/google/callback`。 diff --git a/httpapi/auth/gauth.go b/httpapi/auth/gauth.go index 5e3579d..fc51c33 100644 --- a/httpapi/auth/gauth.go +++ b/httpapi/auth/gauth.go @@ -1,8 +1,11 @@ package authservice import ( + "errors" + "fmt" "net/http" "net/url" + "strings" "github.com/database-playground/backend-v2/internal/auth" "github.com/database-playground/backend-v2/internal/authutil" @@ -16,7 +19,10 @@ import ( "google.golang.org/api/option" ) -const verifierCookieName = "Gauth-Verifier" +const ( + verifierCookieName = "Gauth-Verifier" + redirectCookieName = "Gauth-Redirect" +) // BuildOAuthConfig builds an oauth2.Config from a gauthConfig. func BuildOAuthConfig(gauthConfig config.GAuthConfig) *oauth2.Config { @@ -32,13 +38,13 @@ func BuildOAuthConfig(gauthConfig config.GAuthConfig) *oauth2.Config { } type GauthHandler struct { - oauthConfig *oauth2.Config - useraccount *useraccount.Context - redirectURL string + oauthConfig *oauth2.Config + useraccount *useraccount.Context + redirectURIs []string } -func NewGauthHandler(oauthConfig *oauth2.Config, useraccount *useraccount.Context, redirectURL string) *GauthHandler { - return &GauthHandler{oauthConfig: oauthConfig, useraccount: useraccount, redirectURL: redirectURL} +func NewGauthHandler(oauthConfig *oauth2.Config, useraccount *useraccount.Context, redirectURIs []string) *GauthHandler { + return &GauthHandler{oauthConfig: oauthConfig, useraccount: useraccount, redirectURIs: redirectURIs} } func (h *GauthHandler) Login(c *gin.Context) { @@ -74,6 +80,16 @@ func (h *GauthHandler) Login(c *gin.Context) { /* httpOnly */ true, ) + c.SetCookie( + /* name */ redirectCookieName, + /* value */ c.Request.URL.String(), + /* maxAge */ 5*60, // 5 min + /* path */ "/", + /* domain */ "", + /* secure */ true, + /* httpOnly */ true, + ) + redirectURL := h.oauthConfig.AuthCodeURL( "", oauth2.AccessTypeOnline, @@ -149,6 +165,39 @@ func (h *GauthHandler) Callback(c *gin.Context) { return } + // get redirect URL from cookie + redirectURL, err := c.Cookie(redirectCookieName) + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to get redirect URL", + "detail": err.Error(), + }) + return + } + + // check if the redirect URL is in the allowed redirect URIs + for _, allowedRedirectURI := range h.redirectURIs { + if strings.HasPrefix(redirectURL, allowedRedirectURI) { + redirectURL = allowedRedirectURI + break + } + } + + if redirectURL == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "redirect URL is not allowed", + "detail": fmt.Sprintf("redirect URL is not allowed: %s", redirectURL), + }) + return + } + // write to cookie c.SetCookie( /* name */ auth.CookieAuthToken, @@ -160,5 +209,5 @@ func (h *GauthHandler) Callback(c *gin.Context) { /* httpOnly */ true, ) - c.Redirect(http.StatusTemporaryRedirect, h.redirectURL) + c.Redirect(http.StatusTemporaryRedirect, redirectURL) } diff --git a/httpapi/auth/root.go b/httpapi/auth/root.go index 7aabf10..0b4a6e0 100644 --- a/httpapi/auth/root.go +++ b/httpapi/auth/root.go @@ -34,7 +34,7 @@ func (s *AuthService) Register(router gin.IRouter) { useraccount := useraccount.NewContext(s.entClient, s.storage) - gauthHandler := NewGauthHandler(oauthConfig, useraccount, s.config.GAuth.RedirectURL) + gauthHandler := NewGauthHandler(oauthConfig, useraccount, s.config.GAuth.RedirectURIs) gauth.GET("/login", gauthHandler.Login) gauth.GET("/callback", gauthHandler.Callback) diff --git a/internal/config/models.go b/internal/config/models.go index f69c9c5..2c36602 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -71,9 +71,9 @@ func (c RedisConfig) Validate() error { } type GAuthConfig struct { - ClientID string `env:"CLIENT_ID"` - ClientSecret string `env:"CLIENT_SECRET"` - RedirectURL string `env:"REDIRECT_URL"` + ClientID string `env:"CLIENT_ID"` + ClientSecret string `env:"CLIENT_SECRET"` + RedirectURIs []string `env:"REDIRECT_URIS"` } func (c GAuthConfig) Validate() error { @@ -83,8 +83,8 @@ func (c GAuthConfig) Validate() error { if c.ClientSecret == "" { return errors.New("GAUTH_CLIENT_SECRET is required") } - if c.RedirectURL == "" { - return errors.New("GAUTH_REDIRECT_URL is required") + if len(c.RedirectURIs) == 0 { + return errors.New("GAUTH_REDIRECT_URIS is required") } return nil From 5811129b9814899ab3e6dc3eae5228afbca5afd9 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 25 Aug 2025 00:56:44 +0800 Subject: [PATCH 2/4] docs(gauth): document redirect_uri --- httpapi/auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpapi/auth/README.md b/httpapi/auth/README.md index e6b82ab..91157ab 100644 --- a/httpapi/auth/README.md +++ b/httpapi/auth/README.md @@ -27,6 +27,6 @@ Auth 端點提供適合供網頁應用程式使用的認證 API。 ## Google 登入 -如果您要觸發 Google 登入的流程,請前往 `GET /api/auth/google/login`。 +如果您要觸發 Google 登入的流程,請前往 `GET /api/auth/google/login`。可以帶入 `redirect_uri` 參數來在登入完成後轉導到指定畫面。 這個頁面會重新導向到 Google 的登入頁面,登入後會回到 `POST /api/auth/google/callback` 並進行帳號登入和註冊手續。 From 58b5e4ac22309544d77f328800c62b90de769ce0 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 25 Aug 2025 01:05:23 +0800 Subject: [PATCH 3/4] fix(gauth): get redirect_uri correctly --- httpapi/auth/gauth.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/httpapi/auth/gauth.go b/httpapi/auth/gauth.go index fc51c33..74b35f1 100644 --- a/httpapi/auth/gauth.go +++ b/httpapi/auth/gauth.go @@ -61,6 +61,11 @@ func (h *GauthHandler) Login(c *gin.Context) { return } + redirectURI := c.Query("redirect_uri") + if redirectURI == "" { + redirectURI = h.oauthConfig.RedirectURL + } + callbackURL, err := url.Parse(h.oauthConfig.RedirectURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -82,7 +87,7 @@ func (h *GauthHandler) Login(c *gin.Context) { c.SetCookie( /* name */ redirectCookieName, - /* value */ c.Request.URL.String(), + /* value */ redirectURI, /* maxAge */ 5*60, // 5 min /* path */ "/", /* domain */ "", From 749fb23748444941fb2baf6525bc80af4b536704 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 25 Aug 2025 01:14:25 +0800 Subject: [PATCH 4/4] fix(gauth): prevent prefix-based bypass attacks --- httpapi/auth/gauth.go | 64 +++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/httpapi/auth/gauth.go b/httpapi/auth/gauth.go index 74b35f1..88c7317 100644 --- a/httpapi/auth/gauth.go +++ b/httpapi/auth/gauth.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/url" - "strings" "github.com/database-playground/backend-v2/internal/auth" "github.com/database-playground/backend-v2/internal/authutil" @@ -170,7 +169,18 @@ func (h *GauthHandler) Callback(c *gin.Context) { return } - // get redirect URL from cookie + // write to cookie + c.SetCookie( + /* name */ auth.CookieAuthToken, + /* value */ token, + /* maxAge */ auth.DefaultTokenExpire, + /* path */ "/", + /* domain */ "", + /* secure */ true, + /* httpOnly */ true, + ) + + // redirect to the original redirect URL redirectURL, err := c.Cookie(redirectCookieName) if err != nil { if errors.Is(err, http.ErrNoCookie) { @@ -188,31 +198,37 @@ func (h *GauthHandler) Callback(c *gin.Context) { } // check if the redirect URL is in the allowed redirect URIs - for _, allowedRedirectURI := range h.redirectURIs { - if strings.HasPrefix(redirectURL, allowedRedirectURI) { - redirectURL = allowedRedirectURI - break - } - } - - if redirectURL == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "redirect URL is not allowed", - "detail": fmt.Sprintf("redirect URL is not allowed: %s", redirectURL), + userRedirectURL, err := url.Parse(redirectURL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to parse redirect URL", + "detail": err.Error(), }) return } - // write to cookie - c.SetCookie( - /* name */ auth.CookieAuthToken, - /* value */ token, - /* maxAge */ auth.DefaultTokenExpire, - /* path */ "/", - /* domain */ "", - /* secure */ true, - /* httpOnly */ true, - ) + for _, allowedRedirectURI := range h.redirectURIs { + parsedAllowedRedirectURI, err := url.Parse(allowedRedirectURI) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to parse allowed redirect URI", + "detail": err.Error(), + }) + return + } - c.Redirect(http.StatusTemporaryRedirect, redirectURL) + matched := userRedirectURL.Scheme == parsedAllowedRedirectURI.Scheme && + userRedirectURL.Host == parsedAllowedRedirectURI.Host && + userRedirectURL.Path == parsedAllowedRedirectURI.Path + + if matched { + c.Redirect(http.StatusTemporaryRedirect, parsedAllowedRedirectURI.String()) + return + } + } + + c.JSON(http.StatusBadRequest, gin.H{ + "error": "redirect URL is not allowed", + "detail": fmt.Sprintf("redirect URL is not allowed: %s", redirectURL), + }) }