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",
|
"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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
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("");
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
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 { 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 = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user