diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d42e23e --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +NEXT_PUBLIC_BASE_PATH=/ammo +NEXT_PUBLIC_KEYCLOAK_BASE_URL=https://your-keycloak-url.org +NEXT_PUBLIC_KEYCLOAK_REALM=your-realm +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=your-client-id +IMAGE_API_URL=https://your-image-server-example.com +AUTH_API_PATH=https://your-auth-server-example.com diff --git a/README.md b/README.md index e215bc4..d3bdc0a 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,25 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# AMMO +**A**lt **M**ulig **MO**ttak (AMMO) er en web app for behandling av materiale i produksjonsløype for tekst. -## Getting Started +## Lokalt oppsett +For å kjøre lokalt må du sette de nødvendige miljøvariablene: +```bash +cp .env.example .env.local +``` -First, run the development server: +| Variabelnavn | Standardverdi | Beskrivelse | +|--------------------------------|---------------|------------------------------| +| NEXT_PUBLIC_BASE_PATH | /ammo | Base path for applikasjonen | +| NEXT_PUBLIC_KEYCLOAK_BASE_URL | _N/A_ | URL til keycloak | +| NEXT_PUBLIC_KEYCLOAK_REALM | _N/A_ | Keycloak-realmen | +| NEXT_PUBLIC_KEYCLOAK_CLIENT_ID | _N/A_ | Keycloak-klienten | +| AUTH_API_PATH | _N/A_ | Sti til autentiserings-APIet | +| IMAGE_API_URL | _N/A_ | Sti til bilde-APIet | +Deretter må du kjøre følgende kommandoer: ```bash +npm install npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Applikasjonen finner du nå i nettleseren på [http://localhost:3000/ammo](http://localhost:3000/ammo). diff --git a/k8s/stage/deployment.yml b/k8s/stage/deployment.yml index 6442131..3516106 100644 --- a/k8s/stage/deployment.yml +++ b/k8s/stage/deployment.yml @@ -23,6 +23,26 @@ spec: containerPort: 3000 imagePullPolicy: Always env: + - name: NEXT_PUBLIC_KEYCLOAK_BASE_URL + valueFrom: + secretKeyRef: + name: ammo-secrets + key: keycloak_base_url + - name: NEXT_PUBLIC_KEYCLOAK_REALM + valueFrom: + secretKeyRef: + name: ammo-secrets + key: keycloak_realm + - name: NEXT_PUBLIC_KEYCLOAK_CLIENT_ID + valueFrom: + secretKeyRef: + name: ammo-secrets + key: keycloak_client_id + - name: AUTH_API_PATH + valueFrom: + secretKeyRef: + name: ammo-secrets + key: auth_api_path - name: IMAGE_API_PATH valueFrom: secretKeyRef: diff --git a/next.config.mjs b/next.config.mjs index d76738d..daebb8c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + reactStrictMode: false, output: "standalone", basePath: process.env.NEXT_PUBLIC_BASE_PATH, images: { diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx new file mode 100644 index 0000000..90b5244 --- /dev/null +++ b/src/app/AuthProvider.tsx @@ -0,0 +1,125 @@ +'use client'; + +import {createContext, useCallback, useContext, useEffect, useState} from 'react'; +import {useRouter} from 'next/navigation'; +import keycloakConfig from '@/lib/keycloak'; +import {User} from '@/models/UserToken'; +import {refresh, signIn, signOut} from '@/services/auth.data'; + +interface IAuthContext { + authenticated: boolean; + user?: User; + logout?: () => void; +} + +const AuthContext = createContext({ + authenticated: false, + logout: () => {} +}); + +export const AuthProvider = ({children}: { children: React.ReactNode }) => { + const router = useRouter(); + + const [authenticated, setAuthenticated] = useState(false); + const [user, setUser] = useState(); + const [intervalId, setIntervalId] = useState(); + + const handleNotAuthenticated = useCallback(() => { + setAuthenticated(false); + setUser(undefined); + if (intervalId) { + clearInterval(intervalId); + } + let currentUrl = window.location.href; + // The app dislikes being redirected to a sub-path, redirecting to root to avoid issues + currentUrl = currentUrl.replace(/\/ammo.*/, '/ammo'); + currentUrl = encodeURIComponent(currentUrl); + + window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` + + `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`); + }, [intervalId]); + + useEffect(() => { + const codeInParams = new URLSearchParams(window.location.search).get('code'); + if (codeInParams) { + const redirectUrl = new URLSearchParams({redirectUrl: trimRedirectUrl(window.location.href)}).toString(); + void signIn(codeInParams, redirectUrl).then((token: User) => { + handleIsAuthenticated(token); + router.push('/'); + }).catch((e: Error) => { + console.error('Failed to sign in: ', e.message); + handleNotAuthenticated(); + }); + } else if (user) { + if (user.expires && new Date(user.expires) > new Date()) { + handleIsAuthenticated(user); + } + } else { + handleNotAuthenticated(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleIsAuthenticated = (newUser: User) => { + if (newUser) { + setUser(newUser); + setAuthenticated(true); + } + }; + + const refreshToken = useCallback(async () => { + return refresh(); + }, []); + + const setIntervalToRefreshAccessToken = useCallback(async () => { + if (user?.expires && !intervalId) { + const expiryTime = new Date(user?.expires).getTime() - Date.now(); + if (expiryTime < 1000 * 60 * 4.75) { + await refreshToken(); + } + setIntervalId(window.setInterval(() => { + void refreshToken().then((newUser: User) => { + handleIsAuthenticated(newUser); + }) + .catch((e: Error) => { + console.error('Failed to refresh token: ', e.message); + handleNotAuthenticated(); + }); + }, (1000 * 60 * 4.75))); // Refresh every 4.75 minutes (fifteen seconds before expiry) + } + }, [handleNotAuthenticated, intervalId, refreshToken, user?.expires]); + + useEffect(() => { + void setIntervalToRefreshAccessToken(); + }, [setIntervalToRefreshAccessToken]); + + const trimRedirectUrl= (returnUrl: string): string => { + returnUrl = returnUrl.split('?')[0]; + if (returnUrl.at(-1) === '/') { + returnUrl = returnUrl.slice(0, -1); + } + return returnUrl; + }; + + const logout = async () => { + await signOut() + .then(() => { + handleNotAuthenticated(); + }); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..58b5609 --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,30 @@ +import {NextResponse} from 'next/server'; +import {User, UserToken} from '@/models/UserToken'; +import {getRefreshToken, setUserCookie} from '@/utils/cookieUtils'; + +// POST api/auth/refresh +export async function POST(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return NextResponse.json({error: 'No user token found'}, {status: 401}); + } + + const data = await fetch(`${process.env.AUTH_API_PATH}/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: refreshToken + }); + + const newToken = await data.json() as UserToken; + + if (!newToken || !newToken.name || !newToken.expires) { + return NextResponse.json({error: 'Failed to refresh token'}, {status: 500}); + } + + setUserCookie(newToken); + + const user: User = {name: newToken.name, expires: newToken.expires}; + return NextResponse.json(user, {status: 200}); +} \ No newline at end of file diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts new file mode 100644 index 0000000..3297b86 --- /dev/null +++ b/src/app/api/auth/signin/route.ts @@ -0,0 +1,43 @@ +import {NextRequest, NextResponse} from 'next/server'; +import {User, UserToken} from '@/models/UserToken'; +import {ProblemDetail} from '@/models/ProblemDetail'; +import {setUserCookie} from '@/utils/cookieUtils'; + +interface LoginRequest { + code: string; + redirectUrl: string; +} + +// POST api/auth/signin +export async function POST(req: NextRequest): Promise { + const {code, redirectUrl} = await req.json() as LoginRequest; + const data = await fetch(`${process.env.AUTH_API_PATH}/login?${redirectUrl}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: code + }) + .then(async response => { + if (!response.ok) { + const problemDetail = await response.json() as ProblemDetail; + return NextResponse.json({error: problemDetail.detail}, {status: problemDetail.status}); + } + return response; + }); + + if (data instanceof NextResponse) { + return data; + } + + const userToken = await data.json() as UserToken; + + if (!userToken || !userToken.name || !userToken.expires) { + return NextResponse.json({error: 'Failed to authenticate'}, {status: 500}); + } + + setUserCookie(userToken); + + const user: User = {name: userToken.name, expires: userToken.expires}; + return NextResponse.json(user, {status: 200}); +} diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts new file mode 100644 index 0000000..331af02 --- /dev/null +++ b/src/app/api/auth/signout/route.ts @@ -0,0 +1,26 @@ +import {NextResponse} from 'next/server'; +import {deleteUserToken, getRefreshToken} from '@/utils/cookieUtils'; + +// POST api/auth/signout +export async function POST(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return NextResponse.json({error: 'No user token found'}, {status: 401}); + } + + return await fetch(`${process.env.AUTH_API_PATH}/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: refreshToken + }).then(res => { + if (!res.ok) { + return NextResponse.json({error: 'Failed to logout'}, {status: res.status}); + } + deleteUserToken(); + return NextResponse.json({message: 'Logged out successfully'}, {status: 200}); + }).catch((error: Error) => { + return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500}); + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index a0e206b..2382419 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -31,3 +31,11 @@ line-height: normal; line-break: anywhere; } + +.header { + @apply flex flex-row items-center justify-between p-4; +} + +.button-style { + @apply bg-blue-500 text-white font-bold py-2 px-4 rounded; +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a0a08a8..7c584fc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type {Metadata} from 'next'; import './globals.css'; import {Providers} from '@/app/providers'; +import Header from '@/components/Header'; export const metadata: Metadata = { title: 'AMMO', @@ -16,7 +17,17 @@ export default function RootLayout({ - {children} +
+
+
+
+ {children} +
+
+

Nasjonalbiblioteket © 2024

+
+
+
diff --git a/src/app/providers.tsx b/src/app/providers.tsx index b30d6cf..cfc2b8e 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,11 +1,17 @@ 'use client'; +import {AuthProvider} from '@/app/AuthProvider'; import {NextUIProvider} from '@nextui-org/react'; +import React from 'react'; export function Providers({children}: { children: React.ReactNode }) { return ( - - {children} - + + + + {children} + + + ); -} \ No newline at end of file +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..5efe9af --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,21 @@ +'use client'; + +import {useAuth} from '@/app/AuthProvider'; +import LogoutButton from '@/components/LogoutButton'; + +export default function Header() { + const { authenticated , user } = useAuth(); + return ( + // Placeholder header + + ); +} \ No newline at end of file diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx new file mode 100644 index 0000000..e1d75ef --- /dev/null +++ b/src/components/LogoutButton.tsx @@ -0,0 +1,16 @@ +import {useAuth} from '@/app/AuthProvider'; + +const LogoutButton = () => { + const { logout } = useAuth(); + + return ( + + ); +}; + +export default LogoutButton; \ No newline at end of file diff --git a/src/lib/keycloak.ts b/src/lib/keycloak.ts new file mode 100644 index 0000000..7cf876a --- /dev/null +++ b/src/lib/keycloak.ts @@ -0,0 +1,7 @@ +const keycloakConfig = { + url: process.env.NEXT_PUBLIC_KEYCLOAK_BASE_URL!, + realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM!, + clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID! +}; + +export default keycloakConfig; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..0958482 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,38 @@ +import {NextRequest, NextResponse} from 'next/server'; +import {getUserToken} from '@/utils/cookieUtils'; +import {UserToken} from '@/models/UserToken'; + +const protectedPaths: string[] = []; +const requiredRoles = ['T_relation_avis']; // TODO: Fiks rolle når den er opprettet + +export default function middleware(req: NextRequest) { + const path = req.nextUrl.pathname; + const isProtected = protectedPaths.some(protectedPath => path.includes(protectedPath)); + const userToken = getUserToken(); + const authorized = isAuthorized(userToken); + + if (!isProtected) { + return NextResponse.next(); + } + + if (isProtected && authorized) { + return NextResponse.next(); + } + + return NextResponse.json({error: 'Unauthorized'}, {status: 401}); +} + +function isAuthorized(token?: UserToken) { + if (token) { + if (token.refreshExpires.getTime() > Date.now()) { + return requiredRoles.some(role => token.groups.includes(role)); + } + } + return false; +} + + +export const config = { + // Run on all routes except these + matcher: ['/((?!_next/static|_next/image|.*\\.png$|.*\\.ico$|.*\\.svg$|api/auth).*)'] +}; \ No newline at end of file diff --git a/src/models/ProblemDetail.ts b/src/models/ProblemDetail.ts new file mode 100644 index 0000000..ca4748f --- /dev/null +++ b/src/models/ProblemDetail.ts @@ -0,0 +1,10 @@ +interface ProblemDetail { + type: string; + title: string; + status: number; + detail: string; + instance: string; + timestamp: string; +} + +export type { ProblemDetail }; \ No newline at end of file diff --git a/src/models/UserToken.ts b/src/models/UserToken.ts new file mode 100644 index 0000000..2975de0 --- /dev/null +++ b/src/models/UserToken.ts @@ -0,0 +1,36 @@ +interface SerializedUserToken { + groups: string[]; + name: string; + accessToken: string; + expires: string; + refreshToken: string; + refreshExpires: string; +} + +interface UserToken { + groups: string[]; + name: string; + accessToken: string; + expires: Date; + refreshToken: string; + refreshExpires: Date; +} + +const userTokenBuilder = (userToken: SerializedUserToken): UserToken => { + return { + groups: userToken.groups, + name: userToken.name, + accessToken: userToken.accessToken, + expires: new Date(userToken.expires), + refreshToken: userToken.refreshToken, + refreshExpires: new Date(userToken.refreshExpires) + }; +}; + +interface User { + name: string; + expires: Date; +} + +export type { User, UserToken, SerializedUserToken }; +export { userTokenBuilder }; \ No newline at end of file diff --git a/src/services/auth.data.ts b/src/services/auth.data.ts new file mode 100644 index 0000000..bbefa86 --- /dev/null +++ b/src/services/auth.data.ts @@ -0,0 +1,49 @@ +import {User} from '@/models/UserToken'; +import {ProblemDetail} from '@/models/ProblemDetail'; +import {NextResponse} from 'next/server'; + +export async function signIn(code: string, redirectUrl: string): Promise { + const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/signin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({code, redirectUrl}) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to authenticate'); + } + return response; + }); + return await data.json() as User; +} + +export async function signOut(): Promise { + return await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/signout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async response => { + if (!response.ok) { + const problemDetail = await response.json() as ProblemDetail; + return NextResponse.json({error: problemDetail.detail}, {status: problemDetail.status}); + } + return NextResponse.json({message: 'Logged out successfully'}, {status: 204}); + }) + .catch((error: Error) => { + return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500}); + }); +} + +export async function refresh(): Promise { + const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + return await data.json() as User; +} \ No newline at end of file diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts new file mode 100644 index 0000000..bf11d75 --- /dev/null +++ b/src/utils/cookieUtils.ts @@ -0,0 +1,31 @@ +import {cookies} from 'next/headers'; +import {SerializedUserToken, UserToken, userTokenBuilder} from '@/models/UserToken'; + +export function getUserToken(): UserToken | undefined { + const userCookieValue = cookies().get('user')?.value; + if (!userCookieValue) { + return undefined; + } + return userTokenBuilder(JSON.parse(userCookieValue) as SerializedUserToken); +} + +export function getRefreshToken(): string | undefined { + return getUserToken()?.refreshToken; +} + +export function getUserName(): string | undefined { + return getUserToken()?.name; +} + +export function deleteUserToken() { + cookies().delete('user'); +} + +export function setUserCookie(user: UserToken) { + cookies().set('user', JSON.stringify(user), { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/' + }); +} \ No newline at end of file