Files
ReadMeABook/src/lib/services/auth/LocalAuthProvider.ts
T
kikootwo a97979358f Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
2026-01-28 11:42:00 -05:00

301 lines
8.3 KiB
TypeScript

/**
* Local Auth Provider (Username/Password)
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import bcrypt from 'bcrypt';
import {
IAuthProvider,
LoginInitiation,
CallbackParams,
AuthResult,
UserInfo,
AuthTokens,
} from './IAuthProvider';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('LocalAuth');
interface LocalLoginParams extends CallbackParams {
username: string;
password: string;
}
interface RegisterParams {
username: string;
password: string;
}
export class LocalAuthProvider implements IAuthProvider {
type: 'local' = 'local';
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate login (no-op for local auth)
*/
async initiateLogin(): Promise<LoginInitiation> {
// Local auth doesn't need initiation - return empty
return {};
}
/**
* Handle login with username/password
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { username, password } = params as LocalLoginParams;
if (!username || !password) {
return { success: false, error: 'Username and password required' };
}
// Find user (exclude soft-deleted users)
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
deletedAt: null, // Exclude soft-deleted users
},
});
if (!user) {
return { success: false, error: 'Invalid username or password' };
}
// Check registration status
if (user.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (user.registrationStatus === 'rejected') {
return { success: false, error: 'Account has been rejected' };
}
// Verify password
let passwordValid = false;
try {
// Decrypt the stored hash
const decryptedHash = this.encryptionService.decrypt(user.authToken || '');
passwordValid = await bcrypt.compare(password, decryptedHash);
} catch (error) {
logger.error('Password verification failed', { error: error instanceof Error ? error.message : String(error) });
return { success: false, error: 'Invalid username or password' };
}
if (!passwordValid) {
return { success: false, error: 'Invalid username or password' };
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate tokens
logger.info('Generating tokens for user', {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
authProvider: user.authProvider,
});
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
logger.info('Tokens generated, returning user data');
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
authProvider: 'local',
},
tokens,
};
} catch (error) {
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Register a new user
*/
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
// Validate
if (!username || username.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
if (!password || password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
// Check if registration is enabled
const registrationEnabled = await this.configService.get('auth.registration_enabled');
if (registrationEnabled !== 'true') {
return { success: false, error: 'Registration is disabled' };
}
// Check username uniqueness (only among non-deleted users)
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
deletedAt: null, // Allow reuse of usernames from deleted accounts
},
});
if (existing) {
return { success: false, error: 'Username already taken' };
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Encrypt the hash before storing
const encryptedHash = this.encryptionService.encrypt(passwordHash);
// Determine registration status
const requireApproval = (await this.configService.get('auth.require_admin_approval')) === 'true';
const registrationStatus = requireApproval ? 'pending_approval' : 'approved';
// Check if first user (make admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
isSetupAdmin: isFirstUser,
registrationStatus: isFirstUser ? 'approved' : registrationStatus,
lastLoginAt: new Date(),
},
});
// If requires approval and not first user, return pending status
if (requireApproval && !isFirstUser) {
return {
success: false,
requiresApproval: true,
};
}
// Generate tokens for immediate login
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
authProvider: 'local',
},
tokens,
};
} catch (error) {
logger.error('Registration failed', { error: error instanceof Error ? error.message : String(error) });
return {
success: false,
error: error instanceof Error ? error.message : 'Registration failed',
};
}
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> {
const tokenPayload = {
sub: userInfo.id,
plexId: userInfo.plexId,
username: userInfo.username,
role: userInfo.role || 'user',
};
logger.debug('JWT token payload', { tokenPayload });
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'local') {
return false;
}
// Reject soft-deleted users
if (user.deletedAt) {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(error) });
return false;
}
}
}