From b9e35716ac4f378afe8f4c4df6d59fb3538a640e Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 18:22:42 +0300 Subject: [PATCH] feat: invalid domain warning (#332) * wip * refactor: update domain warning layout * i18n: add domain warning translations * refactor: rework hooks usage * feat: clear timeouts * fix: use useeffect to cleanup timeout * refactor: rework redirects and history storage * refactor: rename domain to root domain --- cmd/root.go | 1 - frontend/bun.lock | 5 - frontend/package.json | 1 - frontend/src/App.tsx | 4 +- .../domain-warning/domain-warning.tsx | 56 +++++++ frontend/src/components/layout/layout.tsx | 37 ++++- frontend/src/components/ui/button.tsx | 2 +- frontend/src/index.css | 2 +- frontend/src/lib/i18n/locales/en-US.json | 16 +- frontend/src/lib/i18n/locales/en.json | 16 +- frontend/src/pages/continue-page.tsx | 156 ++++++++++++------ frontend/src/pages/login-page.tsx | 54 ++++-- frontend/src/pages/logout-page.tsx | 23 ++- frontend/src/pages/totp-page.tsx | 22 ++- frontend/src/pages/unauthorized-page.tsx | 16 +- frontend/src/schemas/app-context-schema.ts | 4 +- internal/bootstrap/app_bootstrap.go | 20 ++- internal/config/config.go | 1 - internal/controller/context_controller.go | 20 ++- internal/controller/oauth_controller.go | 14 +- internal/controller/user_controller.go | 8 +- internal/middleware/context_middleware.go | 6 +- internal/service/auth_service.go | 6 +- internal/utils/app_utils.go | 6 +- 24 files changed, 339 insertions(+), 157 deletions(-) create mode 100644 frontend/src/components/domain-warning/domain-warning.tsx diff --git a/cmd/root.go b/cmd/root.go index 3ae7292..171e043 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -95,7 +95,6 @@ func init() { {"generic-user-url", "", "Generic OAuth user info URL."}, {"generic-name", "Generic", "Generic OAuth provider name."}, {"generic-skip-ssl", false, "Skip SSL verification for the generic OAuth provider."}, - {"disable-continue", false, "Disable continue screen and redirect to app directly."}, {"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."}, {"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"}, {"session-expiry", 86400, "Session (cookie) expiration time in seconds."}, diff --git a/frontend/bun.lock b/frontend/bun.lock index 12b197b..1f98f9c 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -14,7 +14,6 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.6", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", @@ -364,8 +363,6 @@ "@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.41.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/type-utils": "8.41.0", "@typescript-eslint/utils": "8.41.0", "@typescript-eslint/visitor-keys": "8.41.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.41.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw=="], @@ -476,8 +473,6 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.151", "", {}, "sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA=="], diff --git a/frontend/package.json b/frontend/package.json index 2161e05..3d3fc47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,6 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.6", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72b9238..0559b26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,8 +5,8 @@ export const App = () => { const { isLoggedIn } = useUserContext(); if (isLoggedIn) { - return ; + return ; } - return ; + return ; }; diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx new file mode 100644 index 0000000..4f83b23 --- /dev/null +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -0,0 +1,56 @@ +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Button } from "../ui/button"; +import { Trans, useTranslation } from "react-i18next"; +import { useLocation } from "react-router"; + +interface Props { + onClick: () => void; + appUrl: string; + currentUrl: string; +} + +export const DomainWarning = (props: Props) => { + const { onClick, appUrl, currentUrl } = props; + const { t } = useTranslation(); + const { search } = useLocation(); + + const searchParams = new URLSearchParams(search); + const redirectUri = searchParams.get("redirect_uri"); + + return ( + + + {t("domainWarningTitle")} + + }} + /> + + + + + + + + ); +}; diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index 773185b..3461000 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -1,8 +1,10 @@ import { useAppContext } from "@/context/app-context"; import { LanguageSelector } from "../language/language"; import { Outlet } from "react-router"; +import { useCallback, useState } from "react"; +import { DomainWarning } from "../domain-warning/domain-warning"; -export const Layout = () => { +const BaseLayout = ({ children }: { children: React.ReactNode }) => { const { backgroundImage } = useAppContext(); return ( @@ -15,7 +17,38 @@ export const Layout = () => { }} > - + {children} ); }; + +export const Layout = () => { + const { appUrl } = useAppContext(); + const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => { + return window.sessionStorage.getItem("ignoreDomainWarning") === "true"; + }); + const currentUrl = window.location.origin; + + const handleIgnore = useCallback(() => { + window.sessionStorage.setItem("ignoreDomainWarning", "true"); + setIgnoreDomainWarning(true); + }, [setIgnoreDomainWarning]); + + if (!ignoreDomainWarning && appUrl !== currentUrl) { + return ( + + handleIgnore()} + /> + + ); + } + + return ( + + + + ); +}; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index fbb5b27..4badcc1 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -22,7 +22,7 @@ const buttonVariants = cva( "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", warning: - "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40 dark:bg-amber-600", + "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", diff --git a/frontend/src/index.css b/frontend/src/index.css index 0b1ee02..9701636 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -156,7 +156,7 @@ ul { } code { - @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold; + @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all; } .lead { diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 74e422f..b2dd900 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -14,14 +14,14 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +44,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +51,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 74e422f..b2dd900 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -14,14 +14,14 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +44,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +51,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index cc4d432..261be8b 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -11,60 +11,101 @@ import { useUserContext } from "@/context/user-context"; import { isValidUrl } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; import { Navigate, useLocation, useNavigate } from "react-router"; -import DOMPurify from "dompurify"; -import { useState } from "react"; +import { useEffect, useState } from "react"; export const ContinuePage = () => { + const { rootDomain } = useAppContext(); const { isLoggedIn } = useUserContext(); - - if (!isLoggedIn) { - return ; - } - - const { domain, disableContinue } = useAppContext(); const { search } = useLocation(); - const [loading, setLoading] = useState(false); - - const searchParams = new URLSearchParams(search); - const redirectURI = searchParams.get("redirect_uri"); - - if (!redirectURI) { - return ; - } - - if (!isValidUrl(DOMPurify.sanitize(redirectURI))) { - return ; - } - - const handleRedirect = () => { - setLoading(true); - window.location.href = DOMPurify.sanitize(redirectURI); - } - - if (disableContinue) { - handleRedirect(); - } - const { t } = useTranslation(); const navigate = useNavigate(); - const url = new URL(redirectURI); + const [loading, setLoading] = useState(false); + const [showRedirectButton, setShowRedirectButton] = useState(false); - if (!(url.hostname == domain) && !url.hostname.endsWith(`.${domain}`)) { + const searchParams = new URLSearchParams(search); + const redirectUri = searchParams.get("redirect_uri"); + + const isValidRedirectUri = + redirectUri !== null ? isValidUrl(redirectUri) : false; + const redirectUriObj = isValidRedirectUri + ? new URL(redirectUri as string) + : null; + const isTrustedRedirectUri = + redirectUriObj !== null + ? redirectUriObj.hostname === rootDomain || + redirectUriObj.hostname.endsWith(`.${rootDomain}`) + : false; + const isAllowedRedirectProto = + redirectUriObj !== null + ? redirectUriObj.protocol === "https:" || + redirectUriObj.protocol === "http:" + : false; + const isHttpsDowngrade = + redirectUriObj !== null + ? redirectUriObj.protocol === "http:" && + window.location.protocol === "https:" + : false; + + const handleRedirect = () => { + setLoading(true); + window.location.assign(redirectUriObj!.toString()); + }; + + useEffect(() => { + if ( + !isLoggedIn || + !isValidRedirectUri || + !isTrustedRedirectUri || + !isAllowedRedirectProto || + isHttpsDowngrade + ) { + return; + } + + const auto = setTimeout(() => { + handleRedirect(); + }, 100); + + const reveal = setTimeout(() => { + setLoading(false); + setShowRedirectButton(true); + }, 1000); + + return () => { + clearTimeout(auto); + clearTimeout(reveal); + }; + }, []); + + if (!isLoggedIn) { return ( - + + ); + } + + if (!isValidRedirectUri || !isAllowedRedirectProto) { + return ; + } + + if (!isTrustedRedirectUri) { + return ( + - {t("untrustedRedirectTitle")} + {t("continueUntrustedRedirectTitle")} , }} - values={{ domain }} + values={{ rootDomain }} /> @@ -76,7 +117,11 @@ export const ContinuePage = () => { > {t("continueTitle")} - @@ -84,9 +129,9 @@ export const ContinuePage = () => { ); } - if (url.protocol === "http:" && window.location.protocol === "https:") { + if (isHttpsDowngrade) { return ( - + {t("continueInsecureRedirectTitle")} @@ -102,14 +147,14 @@ export const ContinuePage = () => { - - @@ -120,17 +165,18 @@ export const ContinuePage = () => { return ( - {t("continueTitle")} - {t("continueSubtitle")} + + {t("continueRedirectingTitle")} + + {t("continueRedirectingSubtitle")} - - - + {showRedirectButton && ( + + + + )} ); }; diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 53f183f..fd7108c 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -17,23 +17,21 @@ import { useIsMounted } from "@/lib/hooks/use-is-mounted"; import { LoginSchema } from "@/schemas/login-schema"; import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; export const LoginPage = () => { const { isLoggedIn } = useUserContext(); - - if (isLoggedIn) { - return ; - } - - const { configuredProviders, title, oauthAutoRedirect, genericName } = useAppContext(); + const { configuredProviders, title, oauthAutoRedirect, genericName } = + useAppContext(); const { search } = useLocation(); const { t } = useTranslation(); const isMounted = useIsMounted(); + const redirectTimer = useRef(null); + const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); @@ -53,8 +51,8 @@ export const LoginPage = () => { description: t("loginOauthSuccessSubtitle"), }); - setTimeout(() => { - window.location.href = data.data.url; + redirectTimer.current = window.setTimeout(() => { + window.location.replace(data.data.url); }, 500); }, onError: () => { @@ -79,7 +77,7 @@ export const LoginPage = () => { description: t("loginSuccessSubtitle"), }); - setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); @@ -100,6 +98,7 @@ export const LoginPage = () => { if ( oauthConfigured && configuredProviders.includes(oauthAutoRedirect) && + !isLoggedIn && redirectUri ) { oauthMutation.mutate(oauthAutoRedirect); @@ -107,6 +106,26 @@ export const LoginPage = () => { } }, []); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + + if (isLoggedIn && redirectUri) { + return ( + + ); + } + + if (isLoggedIn) { + return ; + } + return ( @@ -126,7 +145,10 @@ export const LoginPage = () => { icon={} className="w-full" onClick={() => oauthMutation.mutate("google")} - loading={oauthMutation.isPending && oauthMutation.variables === "google"} + loading={ + oauthMutation.isPending && + oauthMutation.variables === "google" + } disabled={oauthMutation.isPending || loginMutation.isPending} /> )} @@ -136,7 +158,10 @@ export const LoginPage = () => { icon={} className="w-full" onClick={() => oauthMutation.mutate("github")} - loading={oauthMutation.isPending && oauthMutation.variables === "github"} + loading={ + oauthMutation.isPending && + oauthMutation.variables === "github" + } disabled={oauthMutation.isPending || loginMutation.isPending} /> )} @@ -146,7 +171,10 @@ export const LoginPage = () => { icon={} className="w-full" onClick={() => oauthMutation.mutate("generic")} - loading={oauthMutation.isPending && oauthMutation.variables === "generic"} + loading={ + oauthMutation.isPending && + oauthMutation.variables === "generic" + } disabled={oauthMutation.isPending || loginMutation.isPending} /> )} diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 30b2af8..17693bb 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -11,20 +11,18 @@ import { useUserContext } from "@/context/user-context"; import { capitalize } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; +import { useEffect, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Navigate } from "react-router"; import { toast } from "sonner"; export const LogoutPage = () => { const { provider, username, isLoggedIn, email } = useUserContext(); - - if (!isLoggedIn) { - return ; - } - const { genericName } = useAppContext(); const { t } = useTranslation(); + const redirectTimer = useRef(null); + const logoutMutation = useMutation({ mutationFn: () => axios.post("/api/user/logout"), mutationKey: ["logout"], @@ -33,8 +31,8 @@ export const LogoutPage = () => { description: t("logoutSuccessSubtitle"), }); - setTimeout(async () => { - window.location.replace("/login"); + redirectTimer.current = window.setTimeout(() => { + window.location.assign("/login"); }, 500); }, onError: () => { @@ -44,6 +42,17 @@ export const LogoutPage = () => { }, }); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + + if (!isLoggedIn) { + return ; + } + return ( diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index 7d4ebad..ef05565 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -12,22 +12,19 @@ import { useUserContext } from "@/context/user-context"; import { TotpSchema } from "@/schemas/totp-schema"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { useId } from "react"; +import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; export const TotpPage = () => { const { totpPending } = useUserContext(); - - if (!totpPending) { - return ; - } - const { t } = useTranslation(); const { search } = useLocation(); const formId = useId(); + const redirectTimer = useRef(null); + const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); @@ -39,7 +36,7 @@ export const TotpPage = () => { description: t("totpSuccessSubtitle"), }); - setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); @@ -52,6 +49,17 @@ export const TotpPage = () => { }, }); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + + if (!totpPending) { + return ; + } + return ( diff --git a/frontend/src/pages/unauthorized-page.tsx b/frontend/src/pages/unauthorized-page.tsx index e0bd6ca..007e01c 100644 --- a/frontend/src/pages/unauthorized-page.tsx +++ b/frontend/src/pages/unauthorized-page.tsx @@ -12,6 +12,10 @@ import { Navigate, useLocation, useNavigate } from "react-router"; export const UnauthorizedPage = () => { const { search } = useLocation(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [loading, setLoading] = useState(false); const searchParams = new URLSearchParams(search); const username = searchParams.get("username"); @@ -19,19 +23,15 @@ export const UnauthorizedPage = () => { const groupErr = searchParams.get("groupErr"); const ip = searchParams.get("ip"); - if (!username && !ip) { - return ; - } - - const { t } = useTranslation(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const handleRedirect = () => { setLoading(true); navigate("/login"); }; + if (!username && !ip) { + return ; + } + let i18nKey = "unauthorizedLoginSubtitle"; if (resource) { diff --git a/frontend/src/schemas/app-context-schema.ts b/frontend/src/schemas/app-context-schema.ts index 31ded49..c5d6d85 100644 --- a/frontend/src/schemas/app-context-schema.ts +++ b/frontend/src/schemas/app-context-schema.ts @@ -2,10 +2,10 @@ import { z } from "zod"; export const appContextSchema = z.object({ configuredProviders: z.array(z.string()), - disableContinue: z.boolean(), title: z.string(), genericName: z.string(), - domain: z.string(), + appUrl: z.string(), + rootDomain: z.string(), forgotPasswordMessage: z.string(), oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]), backgroundImage: z.string(), diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index af75aa6..d2ac1b0 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -2,6 +2,7 @@ package bootstrap import ( "fmt" + "net/url" "strings" "tinyauth/internal/config" "tinyauth/internal/controller" @@ -44,15 +45,16 @@ func (app *BootstrapApp) Setup() error { return err } - // Get domain - domain, err := utils.GetUpperDomain(app.Config.AppURL) + // Get root domain + rootDomain, err := utils.GetRootDomain(app.Config.AppURL) if err != nil { return err } // Cookie names - cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0]) + appUrl, _ := url.Parse(app.Config.AppURL) // Already validated + cookieId := utils.GenerateIdentifier(appUrl.Hostname()) sessionCookieName := fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId) csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId) redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId) @@ -63,7 +65,7 @@ func (app *BootstrapApp) Setup() error { OauthWhitelist: app.Config.OAuthWhitelist, SessionExpiry: app.Config.SessionExpiry, SecureCookie: app.Config.SecureCookie, - Domain: domain, + RootDomain: rootDomain, LoginTimeout: app.Config.LoginTimeout, LoginMaxRetries: app.Config.LoginMaxRetries, SessionCookieName: sessionCookieName, @@ -153,7 +155,7 @@ func (app *BootstrapApp) Setup() error { var middlewares []Middleware contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{ - Domain: domain, + RootDomain: rootDomain, }, authService, oauthBrokerService) uiMiddleware := middleware.NewUIMiddleware() @@ -177,10 +179,10 @@ func (app *BootstrapApp) Setup() error { // Create controllers contextController := controller.NewContextController(controller.ContextControllerConfig{ ConfiguredProviders: configuredProviders, - DisableContinue: app.Config.DisableContinue, Title: app.Config.Title, GenericName: app.Config.GenericName, - Domain: domain, + AppURL: app.Config.AppURL, + RootDomain: rootDomain, ForgotPasswordMessage: app.Config.ForgotPasswordMessage, BackgroundImage: app.Config.BackgroundImage, OAuthAutoRedirect: app.Config.OAuthAutoRedirect, @@ -191,7 +193,7 @@ func (app *BootstrapApp) Setup() error { SecureCookie: app.Config.SecureCookie, CSRFCookieName: csrfCookieName, RedirectCookieName: redirectCookieName, - Domain: domain, + RootDomain: rootDomain, }, apiRouter, authService, oauthBrokerService) proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ @@ -199,7 +201,7 @@ func (app *BootstrapApp) Setup() error { }, apiRouter, dockerService, authService) userController := controller.NewUserController(controller.UserControllerConfig{ - Domain: domain, + RootDomain: rootDomain, }, apiRouter, authService) resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{ diff --git a/internal/config/config.go b/internal/config/config.go index c959e26..82050de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,6 @@ type Config struct { GenericUserURL string `mapstructure:"generic-user-url"` GenericName string `mapstructure:"generic-name"` GenericSkipSSL bool `mapstructure:"generic-skip-ssl"` - DisableContinue bool `mapstructure:"disable-continue"` OAuthWhitelist string `mapstructure:"oauth-whitelist"` OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"` SessionExpiry int `mapstructure:"session-expiry"` diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index c7570f0..7cea62f 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -1,6 +1,8 @@ package controller import ( + "fmt" + "net/url" "tinyauth/internal/utils" "github.com/gin-gonic/gin" @@ -15,7 +17,7 @@ type UserContextResponse struct { Name string `json:"name"` Email string `json:"email"` Provider string `json:"provider"` - Oauth bool `json:"oauth"` + OAuth bool `json:"oauth"` TotpPending bool `json:"totpPending"` } @@ -23,10 +25,10 @@ type AppContextResponse struct { Status int `json:"status"` Message string `json:"message"` ConfiguredProviders []string `json:"configuredProviders"` - DisableContinue bool `json:"disableContinue"` Title string `json:"title"` GenericName string `json:"genericName"` - Domain string `json:"domain"` + AppURL string `json:"appUrl"` + RootDomain string `json:"rootDomain"` ForgotPasswordMessage string `json:"forgotPasswordMessage"` BackgroundImage string `json:"backgroundImage"` OAuthAutoRedirect string `json:"oauthAutoRedirect"` @@ -34,10 +36,10 @@ type AppContextResponse struct { type ContextControllerConfig struct { ConfiguredProviders []string - DisableContinue bool Title string GenericName string - Domain string + AppURL string + RootDomain string ForgotPasswordMessage string BackgroundImage string OAuthAutoRedirect string @@ -72,7 +74,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { Name: context.Name, Email: context.Email, Provider: context.Provider, - Oauth: context.OAuth, + OAuth: context.OAuth, TotpPending: context.TotpPending, } @@ -89,14 +91,16 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { } func (controller *ContextController) appContextHandler(c *gin.Context) { + appUrl, _ := url.Parse(controller.Config.AppURL) // no need to check error, validated on startup + c.JSON(200, AppContextResponse{ Status: 200, Message: "Success", ConfiguredProviders: controller.Config.ConfiguredProviders, - DisableContinue: controller.Config.DisableContinue, Title: controller.Config.Title, GenericName: controller.Config.GenericName, - Domain: controller.Config.Domain, + AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host), + RootDomain: controller.Config.RootDomain, ForgotPasswordMessage: controller.Config.ForgotPasswordMessage, BackgroundImage: controller.Config.BackgroundImage, OAuthAutoRedirect: controller.Config.OAuthAutoRedirect, diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index 31b21f0..cfac656 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -23,7 +23,7 @@ type OAuthControllerConfig struct { RedirectCookieName string SecureCookie bool AppURL string - Domain string + RootDomain string } type OAuthController struct { @@ -74,13 +74,13 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { state := service.GenerateState() authURL := service.GetAuthURL(state) - c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) redirectURI := c.Query("redirect_uri") - if redirectURI != "" && utils.IsRedirectSafe(redirectURI, controller.Config.Domain) { + if redirectURI != "" && utils.IsRedirectSafe(redirectURI, controller.Config.RootDomain) { log.Debug().Msg("Setting redirect URI cookie") - c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) } c.JSON(200, gin.H{ @@ -112,7 +112,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) code := c.Query("code") service, exists := controller.Broker.GetService(req.Provider) @@ -189,7 +189,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { redirectURI, err := c.Cookie(controller.Config.RedirectCookieName) - if err != nil || !utils.IsRedirectSafe(redirectURI, controller.Config.Domain) { + if err != nil || !utils.IsRedirectSafe(redirectURI, controller.Config.RootDomain) { log.Debug().Msg("No redirect URI cookie found, redirecting to app root") c.Redirect(http.StatusTemporaryRedirect, controller.Config.AppURL) return @@ -205,6 +205,6 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.Config.AppURL, queries.Encode())) } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index f7f7c9e..f3b7b51 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -22,7 +22,7 @@ type TotpRequest struct { } type UserControllerConfig struct { - Domain string + RootDomain string } type UserController struct { @@ -115,7 +115,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { err := controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ Username: user.Username, Name: utils.Capitalize(req.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.RootDomain), Provider: "username", TotpPending: true, }) @@ -141,7 +141,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { err = controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ Username: req.Username, Name: utils.Capitalize(req.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.RootDomain), Provider: "username", }) @@ -246,7 +246,7 @@ func (controller *UserController) totpHandler(c *gin.Context) { err = controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ Username: user.Username, Name: utils.Capitalize(user.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.Config.RootDomain), Provider: "username", }) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index ee8932a..bca0400 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -12,7 +12,7 @@ import ( ) type ContextMiddlewareConfig struct { - Domain string + RootDomain string } type ContextMiddleware struct { @@ -134,7 +134,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { c.Set("context", &config.UserContext{ Username: user.Username, Name: utils.Capitalize(user.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.Config.RootDomain), Provider: "basic", IsLoggedIn: true, TotpEnabled: user.TotpSecret != "", @@ -146,7 +146,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { c.Set("context", &config.UserContext{ Username: basic.Username, Name: utils.Capitalize(basic.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.Config.RootDomain), Provider: "basic", IsLoggedIn: true, }) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index c7a27c1..f028149 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -28,7 +28,7 @@ type AuthServiceConfig struct { OauthWhitelist string SessionExpiry int SecureCookie bool - Domain string + RootDomain string LoginTimeout int LoginMaxRetries int SessionCookieName string @@ -216,7 +216,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio return err } - c.SetCookie(auth.Config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true) + c.SetCookie(auth.Config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.Config.RootDomain), auth.Config.SecureCookie, true) return nil } @@ -234,7 +234,7 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error { return res.Error } - c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true) + c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.RootDomain), auth.Config.SecureCookie, true) return nil } diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 85a8754..62b9592 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -12,8 +12,8 @@ import ( "github.com/rs/zerolog" ) -// Get upper domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) -func GetUpperDomain(appUrl string) (string, error) { +// Get root domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) +func GetRootDomain(appUrl string) (string, error) { appUrlParsed, err := url.Parse(appUrl) if err != nil { return "", err @@ -88,7 +88,7 @@ func IsRedirectSafe(redirectURL string, domain string) bool { return false } - upper, err := GetUpperDomain(redirectURL) + upper, err := GetRootDomain(redirectURL) if err != nil { return false