diff --git a/docs/api-reference.md b/docs/api-reference.md index 881b7be..d66c88a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -3,4 +3,6 @@ Here are all of the APIs the VTMUNC website uses. Linked are more detailed api documentation, including example headers and responses, as well as which endpoints require authorization. ## Table of Contents -- [/applicants](/docs/api/applicants.md) \ No newline at end of file +- [/api/applicants](/docs/api/applicants.md) +- [/api/login](/docs/api/login.md) +- [/api/logout](/docs/api/logout.md) \ No newline at end of file diff --git a/docs/api/applicants.md b/docs/api/applicants.md index 944bf04..79b0ad3 100644 --- a/docs/api/applicants.md +++ b/docs/api/applicants.md @@ -1,4 +1,4 @@ -# /applicants +# /api/applicants ## Base URL `/api/applicants` diff --git a/docs/api/login.md b/docs/api/login.md new file mode 100644 index 0000000..021ed96 --- /dev/null +++ b/docs/api/login.md @@ -0,0 +1,105 @@ +# /api/login + +## Base URL +`/api/login` + +## Overview +The `/api/login` endpoint handles user authentication for admin access. + +## Endpoints + +### POST `/api/login` + +#### Description +Authenticate a user with email and password to generate an encrypted token stored in a cookie. + +#### Request Body +| Field | Type | Description | +|---------------|--------|--------------------------------------| +| `email` | string | The email of the user. | +| `password` | string | The password of the user. | +| `rememberMe` | bool | Extends length of login session. (Optional; Default: false) | + +#### Response Status Codes +| Status Code | Description | +|-------------|---------------------------| +| 200 OK | Returns admin details. | +| 401 Unauthorized | Invalid email or password. | + +#### Response Body +| Field | Type | Description | +|----------|--------|--------------------------------------| +| `username` | string | The username of the user. | +| `email`| string | The email of the user. | + +#### Example Request +```http +POST /api/login HTTP/1.1 +Host: yourdomain.com +Content-Type: application/json + +{ + "email": "admin@example.com", + "password": "admin_password" +} +``` + +#### Example Response +On success: +```json +{ + "username": "Admin", + "email": "admin@example.com" +} +``` + +On failure: +```json +{ + "message": "Failed validation" +} +``` + +### GET `/api/login` + +#### Description +Check if user is authenticated as admin based on the presence and validity of the stored token in the cookie. + +#### Headers +| Key | Value | +|---------------|------------------------| +| Authorization | Bearer `` | + +#### Response Status Codes +| Status Code | Description | +|-------------|-----------------------------------| +| 200 OK | User authenticated as admin. | +| 401 Unauthorized | Invalid cookie. | + +#### Response Body +| Field | Type | Description | +|----------|--------|--------------------------------------| +| `username` | string | The username of the user. | +| `email`| string | The email of the user. | + +#### Example Request +```http +GET /api/login HTTP/1.1 +Host: yourdomain.com +Authorization: Bearer +``` + +#### Example Response +On success: +```json +{ + "username": "Admin", + "email": "admin@example.com" +} +``` + +On failure: +```json +{ + "message": "Failed validation" +} \ No newline at end of file diff --git a/docs/api/logout.md b/docs/api/logout.md new file mode 100644 index 0000000..9f5fc57 --- /dev/null +++ b/docs/api/logout.md @@ -0,0 +1,31 @@ +# /api/login + +## Base URL +`/api/logout` + +## Overview +The `/api/logout` endpoint logs out user and clears user authentication cookies. + +## Endpoints + +### GET `/api/logout` + +#### Description +Clears user authentication token. Always succeeds and returns status code 200 with no body. + +#### Headers +| Key | Value | +|---------------|------------------------| +| Authorization | Bearer `` | + +#### Response Status Codes +| Status Code | Description | +|-------------|-----------------------------------| +| 200 OK | User logged out. | + +#### Example Request +```http +GET /api/login HTTP/1.1 +Host: yourdomain.com +Authorization: Bearer +``` \ No newline at end of file diff --git a/site/app/api/(auth)/login/route.js b/site/app/api/(auth)/login/route.js new file mode 100644 index 0000000..89c5502 --- /dev/null +++ b/site/app/api/(auth)/login/route.js @@ -0,0 +1,43 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { getHashedAdminPassword, isUserAdmin, encrypt } from "@/app/utils/AuthUtils"; + +const { ADMIN_USERNAME } = process.env; + +const TOKEN_EXPIRATION_SHORT = 3 * 60 * 60 * 1000; // 3 hours +const TOKEN_EXPIRATION_LONG = 30 * 24 * 60 * 60 * 1000; // 30 days + +export async function POST(request) { + try { + // Get email and password for login + const body = await request.json(); + const { email, password, rememberMe = false } = body; + + // Used bcrypt module to stop timing attacks + if (email !== ADMIN_USERNAME || !await bcrypt.compare(password, await getHashedAdminPassword())) { + return NextResponse.json({ message: "Unauthorized"}, { status: 401 }); + } + + // Create encrypted token with user email and expiration time + const expiresIn = rememberMe ? TOKEN_EXPIRATION_LONG : TOKEN_EXPIRATION_SHORT; + const expires = new Date(Date.now() + expiresIn); + const token = await encrypt({ email, expires }, expires); + + // Set cookies 'vtmunc_admin' for admin access + cookies().set("vtmunc_admin", token, { expires, httpOnly: true }); + + return NextResponse.json({ username: "Admin", email: email}, { status: 200 }); + } + catch (e) { + return NextResponse.json({ message: "Unauthorized"}, { status: 401 }); + } +} + +export async function GET(request) { + if (!await isUserAdmin(request)) { + return NextResponse.json({ message: "Invalid cookie"}, { status: 401 }); + } + + return NextResponse.json({ username: "Admin", email: ADMIN_USERNAME}, { status: 200 }); +} diff --git a/site/app/api/(auth)/logout/route.js b/site/app/api/(auth)/logout/route.js new file mode 100644 index 0000000..23d3bec --- /dev/null +++ b/site/app/api/(auth)/logout/route.js @@ -0,0 +1,6 @@ +import { cookies } from "next/headers"; + +export async function GET() { + cookies().delete("vtmunc_admin"); + return new Response("", {status: 200}); +} \ No newline at end of file diff --git a/site/app/api/auth/login/route.js b/site/app/api/auth/login/route.js deleted file mode 100644 index 433cf99..0000000 --- a/site/app/api/auth/login/route.js +++ /dev/null @@ -1,45 +0,0 @@ -import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; -import { encrypt, decrypt } from "@/lib"; - -const { ADMIN_USERNAME, ADMIN_PASSWORD, JWT_SECRET } = process.env; - - -export async function POST(request) { - // Get email and password for login - const body = await request.json(); - const { userEmail, userPass } = body; - - // Verify email and password against our env variables - if (userEmail === ADMIN_USERNAME && userPass === ADMIN_PASSWORD) { - // Create encrypted token with user email and expiration time - const expires = new Date(Date.now() + 3 * 60 * 60 * 1000); - const token = await encrypt({ userEmail, expires }); - - // Set cookies 'vtmunc_admin' for admin access - cookies().set("vtmunc_admin", token, { expires, httpOnly: true }); - - return NextResponse.json({ message: 'Succesful validation' }, { status: 200 }); - } - else { - return NextResponse.json({ message: "Failed validation"}, { status: 401 }); - } -} - -export async function GET(request) { - - // Get token from cookie - const token = request.cookies.get("vtmunc_admin")?.value; - - if (token) { - // Try to decode cookie - try { - const parsed = await decrypt(token); - return NextResponse.json({ message: "Valid" }, { status: 200 }); - } catch (error) { - return NextResponse.json({ message: "Invalid token" }, { status: 200 }); - } - } - - return NextResponse.json({ message: "Invalid or missing cookie" }, { status: 200 }); -} diff --git a/site/app/applicants/page.jsx b/site/app/applicants/page.jsx index 63507f4..e68feaf 100644 --- a/site/app/applicants/page.jsx +++ b/site/app/applicants/page.jsx @@ -53,8 +53,8 @@ export default function DashboardPage() { if (applicants.length == 0) { return (
-
- Loading... +
+ Loading...
) diff --git a/site/app/components/AuthProvider.jsx b/site/app/components/AuthProvider.jsx new file mode 100644 index 0000000..06ddaec --- /dev/null +++ b/site/app/components/AuthProvider.jsx @@ -0,0 +1,84 @@ +"use client" + +import { createContext, useContext, useEffect, useState } from "react" + +const AuthContext = createContext(); + +export default function AuthProvider({ children }) { + const [user, setUser] = useState({}); + + const isAuthenticated = Object.keys(user).length > 0; + + // Attemps to auto login + useEffect(() => { + async function autoLogin() { + try { + const response = await fetch("/api/login"); + if (!response.ok) { + throw new Error("Invalid credentials"); + } + const user = await response.json(); + setUser(user); + } + catch(e) { + } + } + + autoLogin(); + }, []) + + + async function login(email, password, rememberMe = false) { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, rememberMe }), + }); + + if (!response.ok) { + throw new Error("Invalid email and password"); + } + } + + async function logout() { + try { + const response = await fetch("/api/logout"); + + if (!response.ok) { + throw new Error("Server error"); + } + + setUser({}); + window.location.href = "/"; + } + catch (e) { + } + } + + + + return ( + + {children} + + ) +} + +/** + * Objects that can be obtained from useAuth + * + * user: { + * email: string, + * username: string + * } + * + * login: (email: string, password: string, rememberMe?: boolean) => (); + * Logs in user, doesn't automatically call setUser + * Note: throws on server failure and on invalid email and password. Use with try catch statement to get error message + * + * logout: () => (); + * Logouts out user and auto-directs to the home page + */ +export function useAuth() { + return useContext(AuthContext); +} \ No newline at end of file diff --git a/site/app/components/Navbar.jsx b/site/app/components/Navbar.jsx index e2ae0c2..7870661 100644 --- a/site/app/components/Navbar.jsx +++ b/site/app/components/Navbar.jsx @@ -4,9 +4,11 @@ import React from 'react'; import Link from "next/link"; import './Navbar.css'; import { usePathname } from 'next/navigation'; +import { useAuth } from './AuthProvider'; function Navbar() { const path = usePathname(); + const { isAuthenticated, logout } = useAuth(); // If we are on home page or register/success make navbar transparent const isTransparent = path === '/' || path === '/register/success'; @@ -29,6 +31,12 @@ function Navbar() { {/* Resources Sponsors */} Register + { isAuthenticated && ( + <> + Dashboard + + + )} diff --git a/site/app/layout.jsx b/site/app/layout.jsx index 86e8882..ef9bad2 100644 --- a/site/app/layout.jsx +++ b/site/app/layout.jsx @@ -3,6 +3,7 @@ import "./globals.css"; import Navbar from "./components/Navbar"; import Footer from "./components/Footer"; import Script from "next/script"; +import AuthProvider from "./components/AuthProvider"; export const metadata = { title: "VTMUNC", @@ -12,11 +13,12 @@ export const metadata = { export default function RootLayout({ children }) { return ( - - - {children} -