Both are subclasses of an abstract User class which contains almost everything interesting.
263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
// service.ts
|
|
//
|
|
// Core authentication service providing login, logout, registration,
|
|
// password reset, and email verification.
|
|
|
|
import type { Request as ExpressRequest } from "express";
|
|
import {
|
|
type AnonymousUser,
|
|
anonymousUser,
|
|
type User,
|
|
type UserId,
|
|
} from "../user";
|
|
import { hashPassword, verifyPassword } from "./password";
|
|
import type { AuthStore } from "./store";
|
|
import {
|
|
hashToken,
|
|
parseAuthorizationHeader,
|
|
SESSION_COOKIE_NAME,
|
|
} from "./token";
|
|
import { type SessionData, type TokenId, tokenLifetimes } from "./types";
|
|
|
|
type LoginResult =
|
|
| { success: true; token: string; user: User }
|
|
| { success: false; error: string };
|
|
|
|
type RegisterResult =
|
|
| { success: true; user: User; verificationToken: string }
|
|
| { success: false; error: string };
|
|
|
|
type SimpleResult = { success: true } | { success: false; error: string };
|
|
|
|
// Result of validating a request/token - contains both user and session
|
|
export type AuthResult =
|
|
| { authenticated: true; user: User; session: SessionData }
|
|
| { authenticated: false; user: AnonymousUser; session: null };
|
|
|
|
export class AuthService {
|
|
constructor(private store: AuthStore) {}
|
|
|
|
// === Login ===
|
|
|
|
async login(
|
|
email: string,
|
|
password: string,
|
|
authMethod: "cookie" | "bearer",
|
|
metadata?: { userAgent?: string; ipAddress?: string },
|
|
): Promise<LoginResult> {
|
|
const user = await this.store.getUserByEmail(email);
|
|
if (!user) {
|
|
return { success: false, error: "Invalid credentials" };
|
|
}
|
|
|
|
if (!user.isActive()) {
|
|
return { success: false, error: "Account is not active" };
|
|
}
|
|
|
|
const passwordHash = await this.store.getUserPasswordHash(user.id);
|
|
if (!passwordHash) {
|
|
return { success: false, error: "Invalid credentials" };
|
|
}
|
|
|
|
const valid = await verifyPassword(password, passwordHash);
|
|
if (!valid) {
|
|
return { success: false, error: "Invalid credentials" };
|
|
}
|
|
|
|
const { token } = await this.store.createSession({
|
|
userId: user.id,
|
|
tokenType: "session",
|
|
authMethod,
|
|
expiresAt: new Date(Date.now() + tokenLifetimes.session),
|
|
userAgent: metadata?.userAgent,
|
|
ipAddress: metadata?.ipAddress,
|
|
});
|
|
|
|
return { success: true, token, user };
|
|
}
|
|
|
|
// === Session Validation ===
|
|
|
|
async validateRequest(request: ExpressRequest): Promise<AuthResult> {
|
|
// Try cookie first (for web requests)
|
|
let token = this.extractCookieToken(request);
|
|
|
|
// Fall back to Authorization header (for API requests)
|
|
if (!token) {
|
|
token = parseAuthorizationHeader(request.get("Authorization"));
|
|
}
|
|
|
|
if (!token) {
|
|
return { authenticated: false, user: anonymousUser, session: null };
|
|
}
|
|
|
|
return this.validateToken(token);
|
|
}
|
|
|
|
async validateToken(token: string): Promise<AuthResult> {
|
|
const tokenId = hashToken(token) as TokenId;
|
|
const session = await this.store.getSession(tokenId);
|
|
|
|
if (!session) {
|
|
return { authenticated: false, user: anonymousUser, session: null };
|
|
}
|
|
|
|
if (session.tokenType !== "session") {
|
|
return { authenticated: false, user: anonymousUser, session: null };
|
|
}
|
|
|
|
const user = await this.store.getUserById(session.userId as UserId);
|
|
if (!user || !user.isActive()) {
|
|
return { authenticated: false, user: anonymousUser, session: null };
|
|
}
|
|
|
|
// Update last used (fire and forget)
|
|
this.store.updateLastUsed(tokenId).catch(() => {});
|
|
|
|
return { authenticated: true, user, session };
|
|
}
|
|
|
|
private extractCookieToken(request: ExpressRequest): string | null {
|
|
const cookies = request.get("Cookie");
|
|
if (!cookies) {
|
|
return null;
|
|
}
|
|
|
|
for (const cookie of cookies.split(";")) {
|
|
const [name, ...valueParts] = cookie.trim().split("=");
|
|
if (name === SESSION_COOKIE_NAME) {
|
|
return valueParts.join("="); // Handle = in token value
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// === Logout ===
|
|
|
|
async logout(token: string): Promise<void> {
|
|
const tokenId = hashToken(token) as TokenId;
|
|
await this.store.deleteSession(tokenId);
|
|
}
|
|
|
|
async logoutAllSessions(userId: UserId): Promise<number> {
|
|
return this.store.deleteUserSessions(userId);
|
|
}
|
|
|
|
// === Registration ===
|
|
|
|
async register(
|
|
email: string,
|
|
password: string,
|
|
displayName?: string,
|
|
): Promise<RegisterResult> {
|
|
const existing = await this.store.getUserByEmail(email);
|
|
if (existing) {
|
|
return { success: false, error: "Email already registered" };
|
|
}
|
|
|
|
const passwordHash = await hashPassword(password);
|
|
const user = await this.store.createUser({
|
|
email,
|
|
passwordHash,
|
|
displayName,
|
|
});
|
|
|
|
// Create email verification token
|
|
const { token: verificationToken } = await this.store.createSession({
|
|
userId: user.id,
|
|
tokenType: "email_verify",
|
|
authMethod: "bearer",
|
|
expiresAt: new Date(Date.now() + tokenLifetimes.email_verify),
|
|
});
|
|
|
|
return { success: true, user, verificationToken };
|
|
}
|
|
|
|
// === Email Verification ===
|
|
|
|
async verifyEmail(token: string): Promise<SimpleResult> {
|
|
const tokenId = hashToken(token) as TokenId;
|
|
const session = await this.store.getSession(tokenId);
|
|
|
|
if (!session || session.tokenType !== "email_verify") {
|
|
return {
|
|
success: false,
|
|
error: "Invalid or expired verification token",
|
|
};
|
|
}
|
|
|
|
if (session.isUsed) {
|
|
return { success: false, error: "Token already used" };
|
|
}
|
|
|
|
await this.store.updateUserEmailVerified(session.userId as UserId);
|
|
await this.store.deleteSession(tokenId);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// === Password Reset ===
|
|
|
|
async createPasswordResetToken(
|
|
email: string,
|
|
): Promise<{ token: string } | null> {
|
|
const user = await this.store.getUserByEmail(email);
|
|
if (!user) {
|
|
// Don't reveal whether email exists
|
|
return null;
|
|
}
|
|
|
|
const { token } = await this.store.createSession({
|
|
userId: user.id,
|
|
tokenType: "password_reset",
|
|
authMethod: "bearer",
|
|
expiresAt: new Date(Date.now() + tokenLifetimes.password_reset),
|
|
});
|
|
|
|
return { token };
|
|
}
|
|
|
|
async resetPassword(
|
|
token: string,
|
|
newPassword: string,
|
|
): Promise<SimpleResult> {
|
|
const tokenId = hashToken(token) as TokenId;
|
|
const session = await this.store.getSession(tokenId);
|
|
|
|
if (!session || session.tokenType !== "password_reset") {
|
|
return { success: false, error: "Invalid or expired reset token" };
|
|
}
|
|
|
|
if (session.isUsed) {
|
|
return { success: false, error: "Token already used" };
|
|
}
|
|
|
|
const passwordHash = await hashPassword(newPassword);
|
|
await this.store.setUserPassword(
|
|
session.userId as UserId,
|
|
passwordHash,
|
|
);
|
|
|
|
// Invalidate all existing sessions (security: password changed)
|
|
await this.store.deleteUserSessions(session.userId as UserId);
|
|
|
|
// Delete the reset token
|
|
await this.store.deleteSession(tokenId);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// === Token Extraction Helper (for routes) ===
|
|
|
|
extractToken(request: ExpressRequest): string | null {
|
|
// Try Authorization header first
|
|
const token = parseAuthorizationHeader(request.get("Authorization"));
|
|
if (token) {
|
|
return token;
|
|
}
|
|
|
|
// Try cookie
|
|
return this.extractCookieToken(request);
|
|
}
|
|
}
|