diff --git a/.env.example b/.env.example index e767b502..4b3f1004 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,5 @@ S3_REGION="eu-north-1" S3_BUCKET="device-images" S3_ACCESS_KEY="rustfsadmin" S3_SECRET_KEY="rustfsadmin123" + +DISCOURSE_CONNECT_SECRET="iamverysecure" diff --git a/app/components/map/filter-visualization.tsx b/app/components/map/filter-visualization.tsx index b618662c..ae843f26 100644 --- a/app/components/map/filter-visualization.tsx +++ b/app/components/map/filter-visualization.tsx @@ -50,10 +50,10 @@ export default function FilterVisualization() { } // Clean search params when the component mounts - useEffect(() => { - cleanSearchParams() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + // useEffect(() => { + // cleanSearchParams() + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, []) // Group valid filters by key const groupedFilters: { [key: string]: string[] } = {} diff --git a/app/lib/api-routes.ts b/app/lib/api-routes.ts index 34b98595..ffa7dd53 100644 --- a/app/lib/api-routes.ts +++ b/app/lib/api-routes.ts @@ -103,6 +103,11 @@ export const apiRoutes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { method: 'POST', tosExempt: true }, + { + path: `discourse/sso`, + method: 'GET', + tosExempt: true, + } ], auth: [ { diff --git a/app/root.tsx b/app/root.tsx index a3a65993..ad15bc09 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -18,8 +18,8 @@ import { type Route } from './+types/root' import ErrorMessage from './components/error-message' import { Toaster } from './components/ui/toaster' import { getLocale, i18nCookie, i18nextMiddleware } from './middleware/i18next' -import { updateUserlocale } from './models/user.server' import { tosUiMiddleware } from './middleware/tos-ui.server' +import { updateUserlocale } from './models/user.server' import { getEnv } from './utils/env.server' import { getUser } from './utils/session.server' diff --git a/app/routes/api.discourse.sso.ts b/app/routes/api.discourse.sso.ts new file mode 100644 index 00000000..ad63c997 --- /dev/null +++ b/app/routes/api.discourse.sso.ts @@ -0,0 +1,105 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { redirect, type LoaderFunctionArgs } from 'react-router' +import invariant from 'tiny-invariant' +import { getUser } from '~/utils/session.server' + +invariant( + process.env.DISCOURSE_CONNECT_SECRET, + 'DISCOURSE_CONNECT_SECRET must be set', +) + +const DISCOURSE_CONNECT_SECRET = process.env.DISCOURSE_CONNECT_SECRET + +function signPayload(payload: string) { + return createHmac('sha256', DISCOURSE_CONNECT_SECRET).update(payload).digest('hex') +} + +function safeEqualHex(a: string, b: string) { + try { + const ab = Buffer.from(a, 'hex') + const bb = Buffer.from(b, 'hex') + return ab.length === bb.length && timingSafeEqual(ab, bb) + } catch { + return false + } +} + +function decodeDiscoursePayload(sso: string) { + const decoded = Buffer.from(sso, 'base64').toString('utf8') + return new URLSearchParams(decoded) +} + +function encodeDiscoursePayload(params: URLSearchParams) { + return Buffer.from(params.toString(), 'utf8').toString('base64') +} + +function buildAbsoluteLoginRedirect(request: Request) { + const url = new URL(request.url) + const redirectTo = `${url.pathname}${url.search}` + + return `/explore/login?${new URLSearchParams({ redirectTo }).toString()}` +} + + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const sso = url.searchParams.get('sso') + const sig = url.searchParams.get('sig') + + if (!sso || !sig) { + throw new Response('Missing sso or sig', { status: 400 }) + } + + const expectedSig = signPayload(sso) + if (!safeEqualHex(sig, expectedSig)) { + throw new Response('Invalid DiscourseConnect signature', { status: 403 }) + } + + const incoming = decodeDiscoursePayload(sso) + const nonce = incoming.get('nonce') + const returnSsoUrl = incoming.get('return_sso_url') + + if (!nonce || !returnSsoUrl) { + throw new Response('Missing nonce or return_sso_url', { status: 400 }) + } + + const user = await getUser(request) + + if (!user) { + const loginRedirect = buildAbsoluteLoginRedirect(request) + throw redirect(loginRedirect) + } + + if (!user.email) { + throw new Response('User has no email', { status: 400 }) + } + + const username = + user.name ?? + `user_${user.id}` + + const outgoing = new URLSearchParams() + outgoing.set('nonce', nonce) + outgoing.set('external_id', String(user.id)) + outgoing.set('email', user.email) + outgoing.set('username', username) + + if (user.name) { + outgoing.set('name', user.name) + } + + if (!user.emailIsConfirmed) { + outgoing.set('require_activation', 'true') + } + + + const responsePayload = encodeDiscoursePayload(outgoing) + const responseSig = signPayload(responsePayload) + + const redirectUrl = new URL(returnSsoUrl) + redirectUrl.searchParams.set('sso', responsePayload) + redirectUrl.searchParams.set('sig', responseSig) + + + throw redirect(redirectUrl.toString()) +} \ No newline at end of file diff --git a/app/routes/explore.login.tsx b/app/routes/explore.login.tsx index b5594eee..ee50387f 100644 --- a/app/routes/explore.login.tsx +++ b/app/routes/explore.login.tsx @@ -11,6 +11,7 @@ import { useActionData, useNavigation, useSearchParams, + useLoaderData, } from 'react-router' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -31,9 +32,16 @@ import { safeRedirect } from '~/utils' import { createUserSession, getUserId } from '~/utils/session.server' export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) const userId = await getUserId(request) - if (userId) return redirect('/explore') - return {} + if (userId) { + const redirectTo = safeRedirect(url.searchParams.get('redirectTo'), '/explore') + return redirect(redirectTo) + } + + return data({ + redirectTo: safeRedirect(url.searchParams.get('redirectTo'), '/explore'), + }) } export async function action({ request }: ActionFunctionArgs) { @@ -106,6 +114,7 @@ export const meta: MetaFunction = () => { export default function LoginPage() { const [searchParams] = useSearchParams() + const loaderData = useLoaderData() const actionData = useActionData() const identifierRef = React.useRef(null) const passwordRef = React.useRef(null) @@ -148,6 +157,11 @@ export default function LoginPage() { )}
+ {t('welcome_back')}