Skip to content

Commit

Permalink
feat: safe sessions handling in server with communication to central …
Browse files Browse the repository at this point in the history
…command
  • Loading branch information
corp-0 committed Mar 3, 2024
1 parent 28bfd19 commit 983b41c
Show file tree
Hide file tree
Showing 13 changed files with 278 additions and 20 deletions.
21 changes: 21 additions & 0 deletions app/(account)/logout/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client"

import {useContext, useEffect} from "react";
import {logout} from "../../../lib/auth/authorization";
import {redirect} from "next/navigation";
import {AuthorizerContext} from "../../../context/authorizerContext";

const LogoutPage = () => {

const {revalidateAuth} = useContext(AuthorizerContext);

useEffect(() => {
logout().then(_ => {
revalidateAuth();
redirect("/");
}
);
}, []);
}

export default LogoutPage;
2 changes: 1 addition & 1 deletion app/(account)/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const ResetPasswordPage = () => {
<h3 className="text-lg text-center font-medium text-green-800">Success!</h3>
<p>An email has been sent with instructions to reset your password.</p>
<p>Please check your inbox and follow the instructions to complete the process.</p>
<p>Didn't receive the email? Check your Spam folder or try resending the email. Ensure your email address is
<p>Didn&apos;t receive the email? Check your Spam folder or try resending the email. Ensure your email address is
entered correctly.</p>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions app/common/defaultNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const playerWiki = 'https://wiki.unitystation.org'
const devWiki = 'https://unitystation.github.io/unitystation/'

export default function DefaultNavbar() {
const {isLoggedIn, encryptedToken, account} = useContext(AuthorizerContext);
const username = account?.username;
const {isLoggedIn, authContext, error} = useContext(AuthorizerContext);
const username = authContext?.account.username;

return (
<Navbar fluid rounded className="dark:bg-gray-900">
Expand Down
16 changes: 8 additions & 8 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import React from "react";
import Clown from "./clown/clown";
import {Metadata} from "next";
import DefaultNavbar from "./common/defaultNavbar";
import { Analytics } from '@vercel/analytics/react';
import type { Viewport } from 'next'
import {Analytics} from '@vercel/analytics/react';
import type {Viewport} from 'next'
import {Providers} from "./providers";

export const metadata: Metadata = {
Expand Down Expand Up @@ -49,12 +49,12 @@ export default function RootLayout({children,}: { children: React.ReactNode; })
return (
<html>
<body className="dark">
<Clown/>
<Providers>
<DefaultNavbar/>
{children}
</Providers>
<Analytics />
<Clown/>
<Providers >
<DefaultNavbar/>
{children}
</Providers>
<Analytics/>
</body>
</html>
)
Expand Down
13 changes: 12 additions & 1 deletion app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@

import LayoutChildren from "../types/layoutChildren";
import {AuthorizerContext} from "../context/authorizerContext";
import {useAuth} from "../lib/auth/useAuth";
import {useState} from "react";


export const Providers = (props: LayoutChildren) => {

const {isLoggedIn, authContext, error} = useAuth();
const [authState, setAuthState] = useState({isLoggedIn, authContext, error});

const useRevalidateAuth = () => {
const {isLoggedIn, authContext, error} = useAuth();
setAuthState({isLoggedIn, authContext, error});
}

return (
<AuthorizerContext.Provider value={{isLoggedIn: false}}>
<AuthorizerContext.Provider value={{...authState, revalidateAuth: useRevalidateAuth}}>
{props.children}
</AuthorizerContext.Provider>
)
Expand Down
5 changes: 4 additions & 1 deletion context/authorizerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {createContext} from "react";
import {AuthContext} from "../types/authTypes";

export const AuthorizerContext = createContext<AuthContext>({
isLoggedIn: false
isLoggedIn: false,
authContext: undefined,
error: undefined,
revalidateAuth: () => {}
}
)
79 changes: 79 additions & 0 deletions lib/auth/authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use server'

import {LoginResponse} from "../../types/authTypes";
import {SignJWT, jwtVerify} from "jose";
import {cookies} from "next/headers";
import {isLoginResponse, parseError} from "../errors";
import {setSessionCookie} from "./session";

const secretKeyText = `${process.env.SECRET}`;
const secretKey = new TextEncoder().encode(secretKeyText);


export const encryptAuthContext = async (payload: LoginResponse) => {
return await new SignJWT(payload as any)
.setProtectedHeader({alg: "HS256"})
.setIssuedAt()
.setExpirationTime("one day from now")
.sign(secretKey);
}

export const decryptAuthContext = async (token: string) => {
const {payload} = await jwtVerify(token, secretKey, {algorithms: ["HS256"]});

if (isLoginResponse(payload)) {
return payload;
} else {
throw new Error("Invalid token payload structure.");
}
}

export const loginWithCredentials = async (email: string, password: string) => {
const response = await fetch(`${process.env.CC_API_URL}/accounts/login-credentials`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({email, password})
});

const parsed = evaluateResponse(response);

if (isLoginResponse(parsed)) {
await setSessionCookie(parsed)
}

return parsed;
}

export const loginWithToken = async (token: string) => {
const response = await fetch(`${process.env.CC_API_URL}/accounts/login-token`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "include",
body: JSON.stringify({token})
});

const parsed = evaluateResponse(response);

if (isLoginResponse(parsed)) {
await setSessionCookie(parsed);
}

return parsed;
}

export const logout = async () => {
cookies().set("session", "", {expires: new Date(0)});
}

const evaluateResponse = async (response: Response) => {
if (response.ok) {
return await response.json() as LoginResponse;
} else {
const errorData = await response.json();
return parseError(errorData);
}
}
21 changes: 21 additions & 0 deletions lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use server"

import {cookies} from "next/headers";
import {LoginResponse} from "../../types/authTypes";
import {encryptAuthContext} from "./authorization";

export const tryGetSessionCookie = (): { success: boolean, value?: string } => {
const sessionCooke = cookies().get("session")?.value;
if (sessionCooke) {
return { success: true, value: sessionCooke };
}

return { success: false };
}

export const setSessionCookie = async (payload: LoginResponse) => {
cookies().set("session", await encryptAuthContext(payload), {
httpOnly: true,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24)
});
}
78 changes: 78 additions & 0 deletions lib/auth/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client"

import {useEffect, useState} from "react";
import {LoginResponse} from "../../types/authTypes";
import {FieldError, GeneralError, isLoginResponse} from "../errors";
import {decryptAuthContext, loginWithToken, logout} from "./authorization";
import {tryGetSessionCookie, setSessionCookie} from "./session";


export const useAuth = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [authContext, setAuthContext] = useState<LoginResponse | undefined>();
const [error, setError] = useState<GeneralError | FieldError | undefined>();

const getLoggedInState = async (isMounted: boolean) => {
const sessionCookie = tryGetSessionCookie();
if (!sessionCookie.success) {
isMounted && setIsLoggedIn(false);
return;
}

try {
const decrypted = await decryptAuthContext(sessionCookie.value!);
console.log("session cookie", sessionCookie);
console.log("decrypted", decrypted);

const response = await loginWithToken(decrypted.token);
if (isLoginResponse(response)) {
isMounted && setIsLoggedIn(true);
isMounted && setAuthContext(response);
await refreshAuthContext(isMounted);
} else {
isMounted && setIsLoggedIn(false);
isMounted && setError(response);
await logout();
}
} catch (e) {
isMounted && setIsLoggedIn(false);
isMounted && setError({error: 'Unexpected error occurred', status: 500});
await logout();
}
};

const refreshAuthContext = async (isMounted: boolean) => {
if (!isMounted) {
return;
}

const sessionCookie = tryGetSessionCookie();
if (!sessionCookie.success) {
return;
}

const decrypted = await decryptAuthContext(sessionCookie.value!);
if (!isLoginResponse(decrypted)) {
return;
}

const response = await loginWithToken(decrypted.token);
if (!isLoginResponse(response)) {
return;
}

await setSessionCookie(response);
};

useEffect(() => {
let isMounted = true;

getLoggedInState(isMounted);

return () => {
isMounted = false;
};
}, []);

return {isLoggedIn, authContext, error};
};
37 changes: 37 additions & 0 deletions lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {LoginResponse} from "../types/authTypes";

interface ErrorBase {
status: number;
}

export interface GeneralError extends ErrorBase{
error: string;
}

export interface FieldError extends ErrorBase {
error: {
[field: string]: string[];
};
}

export const isLoginResponse = (response: any): response is LoginResponse =>
response && response.account && typeof response.token === 'string';

export const isGeneralError = (error: any): error is GeneralError =>
typeof error.error === 'string';

export const isFieldError = (error: any): error is FieldError =>
typeof error.error === 'object' && !Array.isArray(error.error) && error.error !== null

export const parseError = (error: any) => {
if (isGeneralError(error)) {
return error as GeneralError;
} else if (isFieldError(error)) {
return error as FieldError;
} else {
return {
status: 500,
error: 'Unknown error structure'
} as GeneralError;
}
};
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"classnames": "^2.5.1",
"flowbite": "^2.3.0",
"flowbite-react": "^0.7.2",
"jose": "^5.2.2",
"next": "^14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
12 changes: 5 additions & 7 deletions types/authTypes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {FieldError, GeneralError} from "../lib/errors";

export interface AccountPublicData {
unique_identifier: string;
username: string;
Expand All @@ -10,13 +12,9 @@ export interface LoginResponse {
token: string;
}

export interface LoginWithCredentialsRequest {
email: string;
password: string;
}

export interface AuthContext {
isLoggedIn: boolean;
account?: AccountPublicData;
encryptedToken?: string;
authContext?: LoginResponse;
error?: GeneralError | FieldError;
revalidateAuth: () => void;
}

0 comments on commit 983b41c

Please sign in to comment.