feat: JWT token auth (local + future Authentik)

- JWT via jose (HS256, 7d expiry)
- Login API: POST /api/auth/login → returns token
- Verify API: GET /api/auth/me
- Middleware checks Bearer header or cookie
- Token stored in localStorage + cookie (for SSR)
- Authentik button (disabled, placeholder)
- Auth headers auto-added to API requests
This commit is contained in:
Markov 2026-02-15 19:05:37 +01:00
parent 7187cbcf61
commit e655bba89b
10 changed files with 142 additions and 39 deletions

10
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "team-board-web", "name": "team-board-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"jose": "^6.1.3",
"next": "^15.3", "next": "^15.3",
"react": "^19.1", "react": "^19.1",
"react-dom": "^19.1" "react-dom": "^19.1"
@ -1079,6 +1080,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/lightningcss": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",

View File

@ -9,6 +9,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"jose": "^6.1.3",
"next": "^15.3", "next": "^15.3",
"react": "^19.1", "react": "^19.1",
"react-dom": "^19.1" "react-dom": "^19.1"

View File

@ -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 });
}

View File

@ -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 } });
}

View File

@ -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 });
}

View File

@ -15,16 +15,21 @@ export default function LoginPage() {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/auth", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), 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.push("/");
router.refresh(); router.refresh();
} else { } else {
setError("Неверный логин или пароль"); setError(data.error || "Ошибка авторизации");
} }
} catch { } catch {
setError("Ошибка соединения"); setError("Ошибка соединения");
@ -70,6 +75,18 @@ export default function LoginPage() {
> >
{loading ? "..." : "Войти"} {loading ? "..." : "Войти"}
</button> </button>
<div className="mt-6 text-center">
<div className="text-xs text-[var(--muted)] mb-2">или</div>
<button
type="button"
disabled
className="w-full py-2.5 bg-[var(--card)] border border-[var(--border)] text-[var(--muted)]
rounded-lg text-sm cursor-not-allowed opacity-50"
>
Войти через Authentik (скоро)
</button>
</div>
</form> </form>
</div> </div>
); );

View File

@ -1,8 +1,16 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100";
function getAuthHeaders(): Record<string, string> {
if (typeof window !== "undefined") {
const token = localStorage.getItem("tb_token");
if (token) return { Authorization: `Bearer ${token}` };
}
return {};
}
async function request<T>(path: string, opts?: RequestInit): Promise<T> { async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json", ...opts?.headers }, headers: { "Content-Type": "application/json", ...getAuthHeaders(), ...opts?.headers },
...opts, ...opts,
}); });
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`); if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);

18
src/lib/auth-client.ts Normal file
View File

@ -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";
}

27
src/lib/auth.ts Normal file
View File

@ -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<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(TOKEN_EXPIRY)
.sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<TokenPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as TokenPayload;
} catch {
return null;
}
}

View File

@ -1,12 +1,25 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";
export function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const session = req.cookies.get("tb_session"); // Check Authorization header (API calls)
if (!session || session.value !== "authenticated") { const authHeader = req.headers.get("authorization");
const loginUrl = new URL("/login", req.url); if (authHeader) {
return NextResponse.redirect(loginUrl); 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 = { export const config = {