فهرست منبع

🔐 fix(oauth): stop authorize flow from bouncing to /console; respect `next` and redirect unauthenticated users to consent

Problem
- Starting OAuth from Discourse hit GET /api/oauth/authorize and 302’d to /login?next=/oauth/consent…
- The login page and AuthRedirect always navigated to /console when a session existed, ignoring next, which aborted the OAuth flow and dropped users in the console.

Changes
- Backend (src/oauth/server.go)
  - When not logged in, redirect directly to /oauth/consent?<original_query> instead of /login?next=…
  - Keep no-store headers; preserve the original authorize querystring.
- Frontend
  - web/src/helpers/auth.jsx: AuthRedirect now honors the login page’s next query param and only redirects to safe internal paths (starts with “/”, not “//”); otherwise falls back to /console.
  - web/src/components/auth/LoginForm.jsx: After successful login and after 2FA success, navigate to next when present and safe; otherwise go to /console.

Result
- The OAuth authorize flow now reliably reaches the consent screen.
- On approval, the server issues an authorization code and 302’s back to the client’s redirect_uri (e.g., Discourse), completing SSO as expected.

Security
- Sanitize next to avoid open-redirects by allowing only same-origin internal paths.

Compatibility
- No behavior change for normal username/password sign-ins outside the OAuth flow.
- No changes to token/userinfo endpoints.

Testing
- Manually verified end-to-end with Discourse OAuth2 Basic:
  - authorize → consent → approve → redirect with code
- Lint checks pass for modified files.
t0ng7u 3 ماه پیش
والد
کامیت
380e1b7d56
3فایلهای تغییر یافته به همراه25 افزوده شده و 15 حذف شده
  1. 11 12
      src/oauth/server.go
  2. 9 2
      web/src/components/auth/LoginForm.jsx
  3. 5 1
      web/src/helpers/auth.jsx

+ 11 - 12
src/oauth/server.go

@@ -846,18 +846,17 @@ func HandleAuthorizeRequest(c *gin.Context) {
 	// 检查用户会话(要求已登录)
 	sess := sessions.Default(c)
 	uidVal := sess.Get("id")
-	if uidVal == nil {
-		if mode == "prepare" {
-			c.JSON(http.StatusUnauthorized, gin.H{"error": "login_required"})
-			return
-		}
-		// 重定向到前端登录后回到同意页
-		consentPath := "/oauth/consent?" + c.Request.URL.RawQuery
-		loginPath := "/login?next=" + url.QueryEscape(consentPath)
-		writeNoStore(c)
-		c.Redirect(http.StatusFound, loginPath)
-		return
-	}
+    if uidVal == nil {
+        if mode == "prepare" {
+            c.JSON(http.StatusUnauthorized, gin.H{"error": "login_required"})
+            return
+        }
+        // 直接跳转到同意页,由前端在需要时引导登录,避免已登录用户被/login重定向到/console
+        consentPath := "/oauth/consent?" + c.Request.URL.RawQuery
+        writeNoStore(c)
+        c.Redirect(http.StatusFound, consentPath)
+        return
+    }
 	userID, _ := uidVal.(int)
 	if userID == 0 {
 		// 某些 session 库会将数字解码为 int64

+ 9 - 2
web/src/components/auth/LoginForm.jsx

@@ -176,7 +176,11 @@ const LoginForm = () => {
               centered: true,
             });
           }
-          navigate('/console');
+          // 优先跳回 next(仅允许相对路径)
+          const sp = new URLSearchParams(window.location.search);
+          const next = sp.get('next');
+          const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
+          navigate(isSafeInternalPath ? next : '/console');
         } else {
           showError(message);
         }
@@ -286,7 +290,10 @@ const LoginForm = () => {
     setUserData(data);
     updateAPI();
     showSuccess('登录成功!');
-    navigate('/console');
+    const sp = new URLSearchParams(window.location.search);
+    const next = sp.get('next');
+    const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
+    navigate(isSafeInternalPath ? next : '/console');
   };
 
   // 返回登录页面

+ 5 - 1
web/src/helpers/auth.jsx

@@ -36,7 +36,11 @@ export const AuthRedirect = ({ children }) => {
   const user = localStorage.getItem('user');
 
   if (user) {
-    return <Navigate to='/console' replace />;
+    // 优先使用登录页上的 next 参数(仅允许站内相对路径)
+    const sp = new URLSearchParams(window.location.search);
+    const next = sp.get('next');
+    const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
+    return <Navigate to={isSafeInternalPath ? next : '/console'} replace />;
   }
 
   return children;