458 lines
12 KiB
TypeScript
458 lines
12 KiB
TypeScript
// src/services/auth.ts
|
|
// Copyright (C) 2026 DTP Technologies, LLC
|
|
// All Rights Reserved
|
|
|
|
import jwt from "jsonwebtoken";
|
|
|
|
import { DtpService } from "../lib/service.js";
|
|
import env from "../config/env.js";
|
|
import UserService from "./user.js";
|
|
import SessionService from "./session.js";
|
|
import { IUser } from "../models/user.js";
|
|
import { ISession } from "../models/session.js";
|
|
|
|
export interface JwtPayload {
|
|
userId: string;
|
|
username: string;
|
|
sessionId: string;
|
|
}
|
|
|
|
export interface AuthTokens {
|
|
accessToken: string;
|
|
expiresIn: string;
|
|
}
|
|
|
|
export interface AuthResult {
|
|
success: boolean;
|
|
user: IUser;
|
|
session: ISession;
|
|
tokens: AuthTokens;
|
|
}
|
|
|
|
export class AuthService extends DtpService {
|
|
get name(): string {
|
|
return "AuthService";
|
|
}
|
|
get slug(): string {
|
|
return "auth";
|
|
}
|
|
|
|
private jwtSecret: string;
|
|
private jwtExpiresIn: number = 24 * 60 * 60; // 24 hours in seconds
|
|
private jwtRefreshThreshold: number = 20; // Refresh when < 20 hours remaining
|
|
|
|
constructor() {
|
|
super();
|
|
const secret = env.user.jwtSecret;
|
|
if (!secret) {
|
|
throw new Error(
|
|
"JWT secret not configured. Set environmentSalt or JWT_SECRET in config.",
|
|
);
|
|
}
|
|
this.jwtSecret = secret;
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.log.info("service started", {
|
|
jwtExpiresIn: this.jwtExpiresIn,
|
|
refreshThreshold: this.jwtRefreshThreshold,
|
|
});
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.log.info("service stopped");
|
|
}
|
|
|
|
/**
|
|
* Authenticate user with username and password
|
|
*/
|
|
async authenticate(
|
|
username: string,
|
|
password: string,
|
|
ipAddress?: string,
|
|
userAgent?: string,
|
|
): Promise<AuthResult> {
|
|
this.log.info("authenticate() called", { username, ipAddress, userAgent });
|
|
|
|
// Validate input
|
|
if (!username || !password) {
|
|
this.log.error("authenticate() failed - missing credentials", {
|
|
hasUsername: !!username,
|
|
hasPassword: !!password,
|
|
});
|
|
throw new Error("Username and password are required");
|
|
}
|
|
|
|
this.log.debug("authenticate() - looking up user", { username });
|
|
|
|
// Find user with credentials
|
|
const user = await UserService.getUserByUsernameWithCredentials(username);
|
|
|
|
if (!user) {
|
|
this.log.error("authenticate() failed - user not found", { username });
|
|
throw new Error("Invalid username or password");
|
|
}
|
|
|
|
this.log.debug("authenticate() - user found, verifying password", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
|
|
// Verify password
|
|
const isValid = await UserService.verifyPassword(user, password);
|
|
if (!isValid) {
|
|
this.log.error("authenticate() failed - invalid password", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
throw new Error("Invalid username or password");
|
|
}
|
|
|
|
this.log.debug("authenticate() - password valid, checking ban status", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
|
|
// Check if user is banned
|
|
if (UserService.isUserBanned(user)) {
|
|
this.log.error("authenticate() failed - user is banned", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
throw new Error("Account has been suspended");
|
|
}
|
|
|
|
this.log.debug(
|
|
"authenticate() - user not banned, revoking existing sessions",
|
|
{
|
|
userId: user._id,
|
|
username: user.username,
|
|
},
|
|
);
|
|
|
|
// Revoke any existing sessions (single session policy)
|
|
await SessionService.revokeAllForUser(user._id.toString());
|
|
this.log.debug("authenticate() - existing sessions revoked", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
|
|
// Create new session
|
|
this.log.debug("authenticate() - creating new session", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
const session = await SessionService.create(
|
|
user._id.toString(),
|
|
ipAddress,
|
|
userAgent,
|
|
);
|
|
this.log.debug("authenticate() - session created", {
|
|
sessionId: session._id,
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
|
|
// Generate JWT token
|
|
this.log.debug("authenticate() - generating JWT token", {
|
|
sessionId: session._id,
|
|
userId: user._id,
|
|
username: user.username,
|
|
});
|
|
const tokens = this.generateJwtToken(user, session);
|
|
this.log.debug("authenticate() - JWT token generated", {
|
|
sessionId: session._id,
|
|
userId: user._id,
|
|
username: user.username,
|
|
expiresIn: tokens.expiresIn,
|
|
accessTokenLength: tokens.accessToken.length,
|
|
});
|
|
|
|
this.log.info("User authenticated successfully", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
sessionId: session._id,
|
|
tokenExpiresIn: tokens.expiresIn,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
user,
|
|
session,
|
|
tokens,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate JWT token for user
|
|
*/
|
|
generateJwtToken(user: IUser, session: ISession): AuthTokens {
|
|
this.log.debug("generateJwtToken() - creating JWT", {
|
|
userId: user._id,
|
|
username: user.username,
|
|
sessionId: session._id,
|
|
expiresIn: this.jwtExpiresIn,
|
|
});
|
|
|
|
const payload: JwtPayload = {
|
|
userId: user._id.toString(),
|
|
username: user.username,
|
|
sessionId: session._id.toString(),
|
|
};
|
|
|
|
const accessToken = jwt.sign(payload, this.jwtSecret, {
|
|
expiresIn: this.jwtExpiresIn,
|
|
});
|
|
|
|
this.log.debug("generateJwtToken() - JWT created", {
|
|
sessionId: session._id,
|
|
tokenLength: accessToken.length,
|
|
first20Chars: accessToken.substring(0, 20) + "...",
|
|
});
|
|
|
|
return {
|
|
accessToken,
|
|
expiresIn: `${this.jwtExpiresIn}s`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verify JWT token and return payload
|
|
*/
|
|
verifyToken(token: string): JwtPayload | null {
|
|
this.log.debug("verifyToken() - verifying token", {
|
|
tokenLength: token.length,
|
|
first20Chars: token.substring(0, 20) + "...",
|
|
});
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, this.jwtSecret) as JwtPayload;
|
|
this.log.debug("verifyToken() - token verified successfully", {
|
|
userId: decoded.userId,
|
|
username: decoded.username,
|
|
sessionId: decoded.sessionId,
|
|
});
|
|
return decoded;
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
this.log.error("verifyToken() - token verification failed", {
|
|
errorName: err.name,
|
|
errorMessage: err.message,
|
|
tokenLength: token.length,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if token needs refresh (returns true if should refresh)
|
|
*/
|
|
shouldRefreshToken(token: string): boolean {
|
|
this.log.debug("shouldRefreshToken() - checking token", {
|
|
tokenLength: token.length,
|
|
refreshThreshold: this.jwtRefreshThreshold,
|
|
});
|
|
|
|
try {
|
|
const decoded = jwt.decode(token) as jwt.JwtPayload & JwtPayload;
|
|
if (!decoded || !decoded.exp) {
|
|
this.log.debug("shouldRefreshToken() - no exp claim, not refreshing");
|
|
return false;
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const remainingSeconds = decoded.exp - now;
|
|
const remainingHours = remainingSeconds / 3600;
|
|
|
|
this.log.debug("shouldRefreshToken() - token age analysis", {
|
|
exp: decoded.exp,
|
|
now,
|
|
remainingSeconds,
|
|
remainingHours,
|
|
shouldRefresh: remainingHours < this.jwtRefreshThreshold,
|
|
});
|
|
|
|
return remainingHours < this.jwtRefreshThreshold;
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
this.log.error("shouldRefreshToken() - decode failed", {
|
|
errorMessage: err.message,
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh JWT token
|
|
*/
|
|
async refreshToken(currentToken: string): Promise<AuthTokens | null> {
|
|
this.log.info("refreshToken() - called", {
|
|
tokenLength: currentToken.length,
|
|
first20Chars: currentToken.substring(0, 20) + "...",
|
|
});
|
|
|
|
const payload = this.verifyToken(currentToken);
|
|
if (!payload) {
|
|
this.log.error("refreshToken() - verifyToken failed, returning null");
|
|
return null;
|
|
}
|
|
|
|
this.log.debug("refreshToken() - token verified, checking session", {
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
|
|
// Validate session still exists
|
|
const session = await SessionService.findByToken(payload.sessionId);
|
|
if (!session) {
|
|
this.log.warn("refreshToken() - session not found", {
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
this.log.debug("refreshToken() - session found, fetching user", {
|
|
sessionId: session._id,
|
|
userId: payload.userId,
|
|
});
|
|
|
|
// Get user
|
|
const user = await UserService.getUserByIdWithCredentials(
|
|
new (await import("mongoose")).Types.ObjectId(payload.userId),
|
|
);
|
|
if (!user) {
|
|
this.log.error("refreshToken() - user not found", {
|
|
userId: payload.userId,
|
|
sessionId: payload.sessionId,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
this.log.debug("refreshToken() - user found, extending session", {
|
|
userId: user._id,
|
|
sessionId: session._id,
|
|
});
|
|
|
|
// Extend session
|
|
await SessionService.extend(session);
|
|
this.log.debug("refreshToken() - session extended", {
|
|
sessionId: session._id,
|
|
userId: user._id,
|
|
});
|
|
|
|
// Generate new token
|
|
this.log.info("refreshToken() - generating new token", {
|
|
sessionId: session._id,
|
|
userId: user._id,
|
|
});
|
|
const tokens = this.generateJwtToken(user, session);
|
|
this.log.info("refreshToken() - token refreshed successfully", {
|
|
sessionId: session._id,
|
|
userId: user._id,
|
|
expiresIn: tokens.expiresIn,
|
|
});
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Logout user (revoke session)
|
|
*/
|
|
async logout(token: string): Promise<boolean> {
|
|
this.log.info("logout() - called", {
|
|
tokenLength: token.length,
|
|
first20Chars: token.substring(0, 20) + "...",
|
|
});
|
|
|
|
const payload = this.verifyToken(token);
|
|
if (!payload) {
|
|
this.log.warn("logout() - invalid token, returning false");
|
|
return false;
|
|
}
|
|
|
|
this.log.debug("logout() - token valid, revoking session", {
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
|
|
const revoked = await SessionService.revoke(payload.sessionId);
|
|
if (revoked) {
|
|
this.log.info("logout() - session revoked successfully", {
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
} else {
|
|
this.log.warn("logout() - session revoke failed", {
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
}
|
|
return revoked;
|
|
}
|
|
|
|
/**
|
|
* Get cookie options for JWT
|
|
*/
|
|
getCookieOptions() {
|
|
const cookieDomain = env.web.cookieDomain;
|
|
|
|
this.log.debug("getCookieOptions() - returning cookie config", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "lax",
|
|
maxAge: 24 * 60 * 60 * 1000,
|
|
domain: cookieDomain || "(none - default to request host)",
|
|
});
|
|
|
|
return {
|
|
httpOnly: true,
|
|
secure: true, // Always secure - both prod and dev use HTTPS
|
|
sameSite: "lax" as const, // Allow cross-site for dev proxy
|
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
...(cookieDomain ? { domain: cookieDomain } : {}), // Only set domain if configured
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate Set-Cookie header to clear auth_token cookie
|
|
* This clears ALL duplicate cookies by setting an expired date
|
|
*/
|
|
getClearCookieHeader(): string {
|
|
const options = this.getCookieOptions();
|
|
const expires = "Thu, 01 Jan 1970 00:00:00 GMT";
|
|
|
|
this.log.debug("getClearCookieHeader() - generating clear cookie header", {
|
|
domain: options.domain,
|
|
expires,
|
|
});
|
|
|
|
const parts = ["auth_token=", "Expires=" + expires, "Max-Age=0", "Path=/"];
|
|
|
|
if (options.domain) {
|
|
parts.push("Domain=" + options.domain);
|
|
}
|
|
if (options.httpOnly) {
|
|
parts.push("HttpOnly");
|
|
}
|
|
if (options.secure) {
|
|
parts.push("Secure");
|
|
}
|
|
if (options.sameSite) {
|
|
parts.push("SameSite=" + options.sameSite);
|
|
}
|
|
|
|
const header = parts.join("; ");
|
|
this.log.debug("getClearCookieHeader() - generated header", {
|
|
headerLength: header.length,
|
|
header,
|
|
});
|
|
|
|
return header;
|
|
}
|
|
}
|
|
|
|
export default new AuthService();
|