mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
a50fbc721e
Introduce a shared useApiTokens hook to centralize API token CRUD and UI state (fetch, create, delete, copy, formatting). Refactor ApiTab and ApiTokensSection to consume the hook and remove duplicated logic. Add getInstanceUrl utility for client origin used in curl examples. Include an id alias in TokenPayload and add id into generated JWTs across auth routes and providers; update tests accordingly. Improve auth middleware typing and add debug logging around lastUsedAt updates. Add admin logging when creating a token with a role that differs from the target user's role.
309 lines
8.7 KiB
TypeScript
309 lines
8.7 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' };
|
|
}
|
|
|
|
const normalizedUsername = username.trim().toLowerCase();
|
|
|
|
// Find user (exclude soft-deleted users)
|
|
const user = await prisma.user.findFirst({
|
|
where: {
|
|
plexUsername: normalizedUsername,
|
|
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;
|
|
const normalizedUsername = username?.trim().toLowerCase();
|
|
|
|
// Validate
|
|
if (!normalizedUsername || normalizedUsername.length < 3) {
|
|
return { success: false, error: 'Username must be at least 3 characters' };
|
|
}
|
|
|
|
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
|
if (!password) {
|
|
return { success: false, error: 'Password is required' };
|
|
}
|
|
if (!allowWeakPassword && 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: normalizedUsername,
|
|
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-${normalizedUsername}`,
|
|
plexUsername: normalizedUsername,
|
|
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,
|
|
id: 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;
|
|
}
|
|
}
|
|
}
|