Add expiring panel auth sessions
This commit is contained in:
112
server/lib/auth.ts
Normal file
112
server/lib/auth.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export interface AuthConfig {
|
||||
login: string;
|
||||
password: string;
|
||||
ttlMs: number;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(private readonly config: AuthConfig) {
|
||||
this.secret = config.secret ?? `${config.login}:${config.password}`;
|
||||
}
|
||||
|
||||
login(login: string, password: string) {
|
||||
if (!safeEqual(login, this.config.login) || !safeEqual(password, this.config.password)) {
|
||||
throw new Error('Wrong panel credentials.');
|
||||
}
|
||||
|
||||
const payload: TokenPayload = {
|
||||
sub: this.config.login,
|
||||
exp: Date.now() + this.config.ttlMs,
|
||||
};
|
||||
|
||||
const encoded = encodeBase64Url(JSON.stringify(payload));
|
||||
const signature = this.sign(encoded);
|
||||
|
||||
return {
|
||||
token: `${encoded}.${signature}`,
|
||||
expiresAt: new Date(payload.exp).toISOString(),
|
||||
ttlMs: this.config.ttlMs,
|
||||
};
|
||||
}
|
||||
|
||||
verify(token: string): TokenPayload | null {
|
||||
const [encoded, signature] = token.split('.');
|
||||
|
||||
if (!encoded || !signature || !safeEqual(this.sign(encoded), signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(encoded)) as TokenPayload;
|
||||
|
||||
if (payload.sub !== this.config.login || !Number.isFinite(payload.exp) || payload.exp <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extractBearerToken(request: Request): string | null {
|
||||
const header = request.header('authorization');
|
||||
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
unauthorizedError() {
|
||||
return { error: 'Panel authorization required.' };
|
||||
}
|
||||
|
||||
invalidTokenError() {
|
||||
return { error: 'Panel session is missing or expired.' };
|
||||
}
|
||||
|
||||
private sign(value: string): string {
|
||||
return hmac(value, this.secret);
|
||||
}
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
function hmac(value: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(value).digest('base64url');
|
||||
}
|
||||
Reference in New Issue
Block a user