mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
a97979358f
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.
301 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|