Redirect unauthenticated protected memo access to sign in (#5738)

This commit is contained in:
xun zhao 2026-03-20 19:40:19 +08:00 committed by GitHub
parent 551ee1d81f
commit 7601708ae4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 124 additions and 21 deletions

View File

@ -11,9 +11,14 @@ import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
function PasswordSignInForm() {
interface PasswordSignInFormProps {
redirectPath?: string;
}
function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const { profile } = useInstance();
@ -59,7 +64,7 @@ function PasswordSignInForm() {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
await initialize();
navigateTo("/");
navigateTo(redirectPath || ROUTES.ROOT, { replace: true });
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Failed to sign in.",

View File

@ -10,6 +10,7 @@
"create-your-account": "Create your account",
"host-tip": "You are registering as the Site Host.",
"new-password": "New password",
"protected-memo-notice": "This memo is not public. Sign in to continue.",
"repeat-new-password": "Repeat the new password",
"sign-in-tip": "Already have an account?",
"sign-up-tip": "Don't have an account yet?"
@ -160,6 +161,7 @@
"direction-asc": "Ascending",
"direction-desc": "Descending",
"display-time": "Display Time",
"failed-to-load": "Failed to load memo.",
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",

View File

@ -10,6 +10,7 @@
"create-your-account": "创建您的账户",
"host-tip": "您正在注册为站点管理员。",
"new-password": "新密码",
"protected-memo-notice": "此备忘录不是公开的。请先登录后继续。",
"repeat-new-password": "重复新密码",
"sign-in-tip": "已有账户?",
"sign-up-tip": "还没有账户?"
@ -155,6 +156,7 @@
"direction-asc": "正序",
"direction-desc": "倒序",
"display-time": "展示时间",
"failed-to-load": "加载备忘录失败。",
"filters": {
"has-code": "有代码",
"has-link": "有链接",

View File

@ -10,6 +10,7 @@
"create-your-account": "建立您的帳號",
"host-tip": "您即將註冊為網站管理員。",
"new-password": "新密碼",
"protected-memo-notice": "此備忘錄不是公開的。請先登入後再繼續。",
"repeat-new-password": "再次輸入新密碼",
"sign-in-tip": "已經有帳戶了嗎?",
"sign-up-tip": "還沒有帳戶嗎?"
@ -155,6 +156,7 @@
"direction-asc": "升序",
"direction-desc": "降序",
"display-time": "顯示時間",
"failed-to-load": "載入備忘錄失敗。",
"filters": {
"has-code": "有程式碼",
"has-link": "有連結",

View File

@ -1,4 +1,4 @@
import { ConnectError } from "@connectrpc/connect";
import { Code, ConnectError } from "@connectrpc/connect";
import { ArrowUpLeftFromCircleIcon, MessageCircleIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
@ -14,6 +14,7 @@ import useMediaQuery from "@/hooks/useMediaQuery";
import { useMemo, useMemoComments } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { AUTH_REASON_PROTECTED_MEMO, redirectOnAuthFailure } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
const MemoDetail = () => {
@ -21,7 +22,8 @@ const MemoDetail = () => {
const md = useMediaQuery("md");
const params = useParams();
const navigateTo = useNavigateTo();
const { state: locationState } = useLocation();
const location = useLocation();
const { state: locationState, hash } = location;
const currentUser = useCurrentUser();
const uid = params.uid;
const memoName = `${memoNamePrefix}${uid}`;
@ -31,10 +33,36 @@ const MemoDetail = () => {
const { data: memo, error, isLoading } = useMemo(memoName, { enabled: !!memoName });
// Handle errors
if (error) {
toast.error((error as ConnectError).message);
navigateTo("/403");
}
useEffect(() => {
if (!error) {
return;
}
if (error instanceof ConnectError) {
if (error.code === Code.Unauthenticated) {
redirectOnAuthFailure(true, {
redirect: `${location.pathname}${location.search}${location.hash}`,
reason: AUTH_REASON_PROTECTED_MEMO,
});
return;
}
if (error.code === Code.PermissionDenied) {
navigateTo("/403", { replace: true });
return;
}
if (error.code === Code.NotFound) {
navigateTo("/404", { replace: true });
return;
}
toast.error(error.message);
return;
}
toast.error(t("memo.failed-to-load"));
}, [error, location.hash, location.pathname, location.search, navigateTo, t]);
// Fetch parent memo if exists
const { data: parentMemo } = useMemo(memo?.parent || "", {
@ -47,7 +75,6 @@ const MemoDetail = () => {
});
const comments = commentsResponse?.memos || [];
const { hash } = useLocation();
useEffect(() => {
if (!hash || comments.length === 0) return;
const el = document.getElementById(hash.slice(1));

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import AuthFooter from "@/components/AuthFooter";
import PasswordSignInForm from "@/components/PasswordSignInForm";
import { Button } from "@/components/ui/button";
@ -10,8 +10,9 @@ import { useInstance } from "@/contexts/InstanceContext";
import { absolutifyLink } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { handleError } from "@/lib/error";
import { Routes } from "@/router";
import { ROUTES } from "@/router/routes";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
import { storeOAuthState } from "@/utils/oauth";
@ -20,13 +21,17 @@ const SignIn = () => {
const currentUser = useCurrentUser();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const { generalSetting: instanceGeneralSetting } = useInstance();
const [searchParams] = useSearchParams();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
const authReason = searchParams.get(AUTH_REASON_PARAM);
const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`;
// Redirect to root page if already signed in.
useEffect(() => {
if (currentUser?.name) {
window.location.href = Routes.ROOT;
window.location.href = redirectTarget || ROUTES.ROOT;
}
}, [currentUser]);
}, [currentUser, redirectTarget]);
// Prepare identity provider list.
useEffect(() => {
@ -49,7 +54,7 @@ const SignIn = () => {
try {
// Generate and store secure state parameter with CSRF protection
// Also generate PKCE parameters (code_challenge) for enhanced security if available
const { state, codeChallenge } = await storeOAuthState(identityProvider.name);
const { state, codeChallenge } = await storeOAuthState(identityProvider.name, redirectTarget);
// Build OAuth authorization URL with secure state
// Include PKCE if available (requires HTTPS/localhost for crypto.subtle)
@ -82,15 +87,20 @@ const SignIn = () => {
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{authReason === AUTH_REASON_PROTECTED_MEMO && (
<div className="w-full mb-4 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("auth.protected-memo-notice")}
</div>
)}
{!instanceGeneralSetting.disallowPasswordAuth ? (
<PasswordSignInForm />
<PasswordSignInForm redirectPath={redirectTarget} />
) : (
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
)}
{!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-up-tip")}</span>
<Link to="/auth/signup" className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
<Link to={signUpPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-up")}
</Link>
</p>

View File

@ -3,7 +3,7 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import AuthFooter from "@/components/AuthFooter";
import { Button } from "@/components/ui/button";
@ -14,7 +14,9 @@ import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
const SignUp = () => {
@ -25,6 +27,10 @@ const SignUp = () => {
const [password, setPassword] = useState("");
const { initialize: initAuth } = useAuth();
const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance();
const [searchParams] = useSearchParams();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
const authReason = searchParams.get(AUTH_REASON_PARAM);
const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH;
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
@ -72,7 +78,7 @@ const SignUp = () => {
await initAuth();
// Refetch instance profile to update the initialized status
await initInstance();
navigateTo("/");
navigateTo(redirectTarget || ROUTES.ROOT, { replace: true });
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Sign up failed",
@ -88,6 +94,11 @@ const SignUp = () => {
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{authReason === AUTH_REASON_PROTECTED_MEMO && (
<div className="w-full mb-4 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("auth.protected-memo-notice")}
</div>
)}
{!instanceGeneralSetting.disallowUserRegistration ? (
<>
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>
@ -140,7 +151,7 @@ const SignUp = () => {
) : (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-in-tip")}</span>
<Link to="/auth" className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
<Link to={signInPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-in")}
</Link>
</p>

View File

@ -9,12 +9,51 @@ const PUBLIC_ROUTES = [
"/memos/", // Individual memo detail pages (dynamic)
] as const;
export const AUTH_REDIRECT_PARAM = "redirect";
export const AUTH_REASON_PARAM = "reason";
export const AUTH_REASON_PROTECTED_MEMO = "protected-memo";
function isPublicRoute(path: string): boolean {
return PUBLIC_ROUTES.some((route) => path.startsWith(route));
}
export function redirectOnAuthFailure(forceRedirect = false): void {
export function getSafeRedirectPath(path: string | null | undefined): string | undefined {
if (!path) {
return undefined;
}
if (!path.startsWith("/") || path.startsWith("//")) {
return undefined;
}
return path;
}
export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string {
const searchParams = new URLSearchParams();
const redirectPath = getSafeRedirectPath(options?.redirect);
if (redirectPath) {
searchParams.set(AUTH_REDIRECT_PARAM, redirectPath);
}
if (options?.reason) {
searchParams.set(AUTH_REASON_PARAM, options.reason);
}
const search = searchParams.toString();
return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH;
}
export function redirectOnAuthFailure(
forceRedirect = false,
options?: {
redirect?: string | null;
reason?: string | null;
},
): void {
const currentPath = window.location.pathname;
const currentRedirectPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
// Already on auth page, nothing to do.
if (currentPath.startsWith(ROUTES.AUTH)) {
@ -27,5 +66,10 @@ export function redirectOnAuthFailure(forceRedirect = false): void {
}
clearAccessToken();
window.location.replace(ROUTES.AUTH);
window.location.replace(
buildAuthRoute({
...options,
redirect: options?.redirect ?? currentRedirectPath,
}),
);
}