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:
parent
7187cbcf61
commit
e655bba89b
10
package-lock.json
generated
10
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.1.3",
|
||||
"next": "^15.3",
|
||||
"react": "^19.1",
|
||||
"react-dom": "^19.1"
|
||||
|
||||
20
src/app/api/auth/login/route.ts
Normal file
20
src/app/api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
18
src/app/api/auth/me/route.ts
Normal file
18
src/app/api/auth/me/route.ts
Normal 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 } });
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
@ -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 ? "..." : "Войти"}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
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> {
|
||||
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()}`);
|
||||
|
||||
18
src/lib/auth-client.ts
Normal file
18
src/lib/auth-client.ts
Normal 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
27
src/lib/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user