diff --git a/package-lock.json b/package-lock.json index 3dbf66d..11bbe0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "team-board-web", "version": "0.1.0", "dependencies": { + "jose": "^6.1.3", "next": "^15.3", "react": "^19.1", "react-dom": "^19.1" @@ -1079,6 +1080,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", diff --git a/package.json b/package.json index 56c7cbd..48a77e2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "jose": "^6.1.3", "next": "^15.3", "react": "^19.1", "react-dom": "^19.1" diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..2785c46 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createToken } from "@/lib/auth"; + +const AUTH_USER = process.env.AUTH_USER || "admin"; +const AUTH_PASS = process.env.AUTH_PASS || "teamboard"; + +export async function POST(req: NextRequest) { + const { username, password } = await req.json(); + + if (username === AUTH_USER && password === AUTH_PASS) { + const token = await createToken({ + sub: username, + name: username, + provider: "local", + }); + return NextResponse.json({ token, user: { name: username, provider: "local" } }); + } + + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..27b5a8d --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyToken } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (!token) { + return NextResponse.json({ error: "No token" }, { status: 401 }); + } + + const payload = await verifyToken(token); + if (!payload) { + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + } + + return NextResponse.json({ user: { name: payload.name, provider: payload.provider } }); +} diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts deleted file mode 100644 index 37ffac1..0000000 --- a/src/app/api/auth/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { cookies } from "next/headers"; -import { NextRequest, NextResponse } from "next/server"; - -const AUTH_USER = process.env.AUTH_USER || "admin"; -const AUTH_PASS = process.env.AUTH_PASS || "teamboard"; -const COOKIE_NAME = "tb_session"; -const SESSION_TOKEN = "authenticated"; - -export async function POST(req: NextRequest) { - const { username, password } = await req.json(); - if (username === AUTH_USER && password === AUTH_PASS) { - const jar = await cookies(); - jar.set(COOKIE_NAME, SESSION_TOKEN, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 7 days - path: "/", - }); - return NextResponse.json({ ok: true }); - } - return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 }); -} - -export async function DELETE() { - const jar = await cookies(); - jar.delete(COOKIE_NAME); - return NextResponse.json({ ok: true }); -} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d051c0a..4265a4c 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -15,16 +15,21 @@ export default function LoginPage() { setError(""); setLoading(true); try { - const res = await fetch("/api/auth", { + const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); - if (res.ok) { + const data = await res.json(); + if (res.ok && data.token) { + // Store token + localStorage.setItem("tb_token", data.token); + // Also set cookie for SSR middleware + document.cookie = `tb_token=${data.token}; path=/; max-age=${7 * 24 * 3600}; samesite=lax`; router.push("/"); router.refresh(); } else { - setError("Неверный логин или пароль"); + setError(data.error || "Ошибка авторизации"); } } catch { setError("Ошибка соединения"); @@ -70,6 +75,18 @@ export default function LoginPage() { > {loading ? "..." : "Войти"} + +
+
или
+ +
); diff --git a/src/lib/api.ts b/src/lib/api.ts index 6cb4b9f..2289a5d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,8 +1,16 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; +function getAuthHeaders(): Record { + if (typeof window !== "undefined") { + const token = localStorage.getItem("tb_token"); + if (token) return { Authorization: `Bearer ${token}` }; + } + return {}; +} + async function request(path: string, opts?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { - headers: { "Content-Type": "application/json", ...opts?.headers }, + headers: { "Content-Type": "application/json", ...getAuthHeaders(), ...opts?.headers }, ...opts, }); if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..01467b1 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,18 @@ +/** + * Client-side auth helpers. + */ + +export function getToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("tb_token"); +} + +export function isAuthenticated(): boolean { + return !!getToken(); +} + +export function logout() { + localStorage.removeItem("tb_token"); + document.cookie = "tb_token=; path=/; max-age=0"; + window.location.href = "/login"; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..ae6091f --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,27 @@ +import { SignJWT, jwtVerify } from "jose"; + +const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "team-board-dev-secret-change-me"); +const TOKEN_EXPIRY = "7d"; + +export interface TokenPayload { + sub: string; // username + name: string; // display name + provider: string; // "local" | "authentik" +} + +export async function createToken(payload: TokenPayload): Promise { + return new SignJWT({ ...payload }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(TOKEN_EXPIRY) + .sign(JWT_SECRET); +} + +export async function verifyToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + return payload as unknown as TokenPayload; + } catch { + return null; + } +} diff --git a/src/middleware.ts b/src/middleware.ts index b04fc23..4b4fa1e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,12 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; +import { verifyToken } from "@/lib/auth"; -export function middleware(req: NextRequest) { - const session = req.cookies.get("tb_session"); - if (!session || session.value !== "authenticated") { - const loginUrl = new URL("/login", req.url); - return NextResponse.redirect(loginUrl); +export async function middleware(req: NextRequest) { + // Check Authorization header (API calls) + const authHeader = req.headers.get("authorization"); + if (authHeader) { + const token = authHeader.replace("Bearer ", ""); + const payload = await verifyToken(token); + if (payload) return NextResponse.next(); + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); } - return NextResponse.next(); + + // Check cookie (browser navigation) + const token = req.cookies.get("tb_token")?.value; + if (token) { + const payload = await verifyToken(token); + if (payload) return NextResponse.next(); + } + + // Redirect to login + return NextResponse.redirect(new URL("/login", req.url)); } export const config = {