diff --git a/app/(account)/logout/page.tsx b/app/(account)/logout/page.tsx new file mode 100644 index 0000000..c8e7b41 --- /dev/null +++ b/app/(account)/logout/page.tsx @@ -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; \ No newline at end of file diff --git a/app/(account)/reset-password/page.tsx b/app/(account)/reset-password/page.tsx index b88781d..6dba675 100644 --- a/app/(account)/reset-password/page.tsx +++ b/app/(account)/reset-password/page.tsx @@ -22,7 +22,7 @@ const ResetPasswordPage = () => {

Success!

An email has been sent with instructions to reset your password.

Please check your inbox and follow the instructions to complete the process.

-

Didn't receive the email? Check your Spam folder or try resending the email. Ensure your email address is +

Didn't receive the email? Check your Spam folder or try resending the email. Ensure your email address is entered correctly.

); diff --git a/app/common/defaultNavbar.tsx b/app/common/defaultNavbar.tsx index bd02fec..0696800 100644 --- a/app/common/defaultNavbar.tsx +++ b/app/common/defaultNavbar.tsx @@ -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 ( diff --git a/app/layout.tsx b/app/layout.tsx index 8a2aaa0..6e22f44 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 = { @@ -49,12 +49,12 @@ export default function RootLayout({children,}: { children: React.ReactNode; }) return ( - - - - {children} - - + + + + {children} + + ) diff --git a/app/providers.tsx b/app/providers.tsx index bced9d9..77c3b6d 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -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 ( - + {props.children} ) diff --git a/context/authorizerContext.ts b/context/authorizerContext.ts index 674fc14..872c3dd 100644 --- a/context/authorizerContext.ts +++ b/context/authorizerContext.ts @@ -2,6 +2,9 @@ import {createContext} from "react"; import {AuthContext} from "../types/authTypes"; export const AuthorizerContext = createContext({ - isLoggedIn: false + isLoggedIn: false, + authContext: undefined, + error: undefined, + revalidateAuth: () => {} } ) \ No newline at end of file diff --git a/lib/auth/authorization.ts b/lib/auth/authorization.ts new file mode 100644 index 0000000..1d5d2f4 --- /dev/null +++ b/lib/auth/authorization.ts @@ -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); + } +} \ No newline at end of file diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..c1fd3a4 --- /dev/null +++ b/lib/auth/session.ts @@ -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) + }); +} \ No newline at end of file diff --git a/lib/auth/useAuth.ts b/lib/auth/useAuth.ts new file mode 100644 index 0000000..ed23d75 --- /dev/null +++ b/lib/auth/useAuth.ts @@ -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(); + const [error, setError] = useState(); + + 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}; +}; \ No newline at end of file diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..bbcf6e3 --- /dev/null +++ b/lib/errors.ts @@ -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; + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 72ac66c..b72ee01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,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", @@ -10509,6 +10510,14 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz", + "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 2d7bd5e..b90864a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/types/authTypes.ts b/types/authTypes.ts index eee0ec7..6bf4af5 100644 --- a/types/authTypes.ts +++ b/types/authTypes.ts @@ -1,3 +1,5 @@ +import {FieldError, GeneralError} from "../lib/errors"; + export interface AccountPublicData { unique_identifier: string; username: string; @@ -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; } \ No newline at end of file