mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Merge pull request #146 from Orvanix/feature/login-token
feat(auth): add admin-generated login tokens for authentication
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
## Authentication & Users
|
## Authentication & Users
|
||||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
|
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
|||||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||||
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
||||||
|
|
||||||
|
## Admin-Generated Login Token
|
||||||
|
|
||||||
|
- Login token stored as SHA-256 hash in `User.loginTokenHash`
|
||||||
|
- Admin generates/revokes via user permissions modal
|
||||||
|
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
|
||||||
|
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
|
||||||
|
- Invalid token redirects to `/login`
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Never log tokens
|
- Never log tokens
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add login_token_hash column for admin-generated login tokens
|
||||||
|
ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT;
|
||||||
@@ -57,6 +57,9 @@ model User {
|
|||||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||||
|
|
||||||
|
// Login token (admin-generated, for direct URL login)
|
||||||
|
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
|
||||||
|
|
||||||
// Soft delete support
|
// Soft delete support
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface User {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
_count: {
|
_count: {
|
||||||
requests: number;
|
requests: number;
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
|||||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
if (newValue) {
|
||||||
|
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
|
||||||
|
setGeneratedToken(result.fullToken);
|
||||||
|
toast.success(`Login token generated for ${user.plexUsername}`);
|
||||||
|
} else {
|
||||||
|
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
|
||||||
|
setGeneratedToken(null);
|
||||||
|
toast.success(`Login token revoked for ${user.plexUsername}`);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showEditDialog = (user: User) => {
|
const showEditDialog = (user: User) => {
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditDialog({ isOpen: true, user });
|
setEditDialog({ isOpen: true, user });
|
||||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
|||||||
{/* User Permissions Modal */}
|
{/* User Permissions Modal */}
|
||||||
<UserPermissionsModal
|
<UserPermissionsModal
|
||||||
isOpen={permissionsUser !== null}
|
isOpen={permissionsUser !== null}
|
||||||
onClose={() => setPermissionsUserId(null)}
|
onClose={() => {
|
||||||
|
setPermissionsUserId(null);
|
||||||
|
setGeneratedToken(null);
|
||||||
|
}}
|
||||||
user={permissionsUser}
|
user={permissionsUser}
|
||||||
globalAutoApprove={globalAutoApprove}
|
globalAutoApprove={globalAutoApprove}
|
||||||
globalInteractiveSearch={globalInteractiveSearch}
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
globalDownloadAccess={globalDownloadAccess}
|
globalDownloadAccess={globalDownloadAccess}
|
||||||
|
generatedToken={generatedToken}
|
||||||
onToggleAutoApprove={(user, newValue) => {
|
onToggleAutoApprove={(user, newValue) => {
|
||||||
handleUserAutoApproveToggle(user as User, newValue);
|
handleUserAutoApproveToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
|||||||
onToggleDownloadAccess={(user, newValue) => {
|
onToggleDownloadAccess={(user, newValue) => {
|
||||||
handleUserDownloadAccessToggle(user as User, newValue);
|
handleUserDownloadAccessToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
|
onToggleToken={(user, newValue) => {
|
||||||
|
handleToggleToken(user, newValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.deletedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot generate token for deleted user' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullToken, tokenHash } = generateApiToken();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: tokenHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin generated login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fullToken }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin revoked login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
revokedBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
interactiveSearchAccess: true,
|
interactiveSearchAccess: true,
|
||||||
downloadAccess: true,
|
downloadAccess: true,
|
||||||
|
loginTokenHash: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
requests: true,
|
requests: true,
|
||||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({
|
||||||
|
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||||
|
...u,
|
||||||
|
hasLoginToken: loginTokenHash !== null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Route
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||||
|
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
loginTokenHash: tokenHash,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
plexEmail: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn('Token login failed - not found or user deleted');
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
sub: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
|
username: user.plexUsername,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
logger.info('Token login successful', { username: user.plexUsername });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.plexUsername,
|
||||||
|
email: user.plexEmail,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token login error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Page
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
function TokenLoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { setAuthData } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/auth/token/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
setAuthData(data.user, data.accessToken);
|
||||||
|
window.location.href = '/';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
router.replace('/login');
|
||||||
|
});
|
||||||
|
}, [searchParams, router, setAuthData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400 text-sm">Authenticating...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenLoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<TokenLoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
interface UserPermissionsUser {
|
interface UserPermissionsUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserPermissionsModalProps {
|
interface UserPermissionsModalProps {
|
||||||
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
|
|||||||
globalAutoApprove: boolean;
|
globalAutoApprove: boolean;
|
||||||
globalInteractiveSearch: boolean;
|
globalInteractiveSearch: boolean;
|
||||||
globalDownloadAccess: boolean;
|
globalDownloadAccess: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
|
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionToggleProps {
|
interface PermissionToggleProps {
|
||||||
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoginTokenRowProps {
|
||||||
|
value: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const loginUrl = generatedToken
|
||||||
|
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!loginUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(loginUrl);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||||
|
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
aria-label="Login Token"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
value ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Login Token
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, this user can log in via a direct URL without credentials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginUrl && (
|
||||||
|
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
|
||||||
|
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||||
|
Copy the login URL - it won't be shown again
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
|
||||||
|
{loginUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyUrl}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
|
||||||
|
aria-label="Copy login URL"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UserPermissionsModal({
|
export function UserPermissionsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
|
|||||||
globalAutoApprove,
|
globalAutoApprove,
|
||||||
globalInteractiveSearch,
|
globalInteractiveSearch,
|
||||||
globalDownloadAccess,
|
globalDownloadAccess,
|
||||||
|
generatedToken,
|
||||||
onToggleAutoApprove,
|
onToggleAutoApprove,
|
||||||
onToggleInteractiveSearch,
|
onToggleInteractiveSearch,
|
||||||
onToggleDownloadAccess,
|
onToggleDownloadAccess,
|
||||||
|
onToggleToken,
|
||||||
}: UserPermissionsModalProps) {
|
}: UserPermissionsModalProps) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
|
|||||||
description="When enabled, this user can download audiobook files directly"
|
description="When enabled, this user can download audiobook files directly"
|
||||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Login Token */}
|
||||||
|
<LoginTokenRow
|
||||||
|
value={user.hasLoginToken || generatedToken !== null}
|
||||||
|
generatedToken={generatedToken}
|
||||||
|
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Component: Auth Rate Limiting
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*
|
||||||
|
* In-memory fixed-window rate limiter with lazy eviction and periodic sweep
|
||||||
|
* to prevent unbounded memory growth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
/** Number of checkRateLimit calls since the last full sweep */
|
||||||
|
let checkCount = 0;
|
||||||
|
|
||||||
|
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||||
|
const SWEEP_INTERVAL = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep the entire bucket map and delete all expired entries.
|
||||||
|
* Called automatically every SWEEP_INTERVAL checks.
|
||||||
|
*/
|
||||||
|
function sweepExpiredBuckets(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, bucket] of buckets) {
|
||||||
|
if (now >= bucket.resetAt) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||||
|
checkCount += 1;
|
||||||
|
if (checkCount >= SWEEP_INTERVAL) {
|
||||||
|
checkCount = 0;
|
||||||
|
sweepExpiredBuckets();
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = buckets.get(key);
|
||||||
|
|
||||||
|
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||||
|
if (!current || now >= current.resetAt) {
|
||||||
|
if (current) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.count >= maxRequests) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
current.count += 1;
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 10 attempts per 15 minutes per IP */
|
||||||
|
export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all buckets and the sweep counter. For testing only. */
|
||||||
|
export function _resetBuckets(): void {
|
||||||
|
buckets.clear();
|
||||||
|
checkCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current number of tracked buckets. For testing only. */
|
||||||
|
export function _getBucketCount(): number {
|
||||||
|
return buckets.size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token Tests
|
||||||
|
* Documentation: documentation/testing.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
let authRequest: any;
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||||
|
const generateApiTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
|
requireAuth: requireAuthMock,
|
||||||
|
requireAdmin: requireAdminMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/api-token', () => ({
|
||||||
|
generateApiToken: generateApiTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Admin login token routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authRequest = { user: { id: 'admin-1', username: 'admin', role: 'admin' }, json: vi.fn() };
|
||||||
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||||
|
generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token', tokenHash: 'hash_abc123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/admin/users/[id]/login-token', () => {
|
||||||
|
it('generates a login token for an active user', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'u1' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(payload.fullToken).toBe('rmab_test_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when user does not exist', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(payload.error).toMatch(/User not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when user is deleted', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
plexUsername: 'deleteduser',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'u2' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(payload.error).toMatch(/deleted user/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/admin/users/[id]/login-token', () => {
|
||||||
|
it('revokes the login token for a user', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u1' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when user does not exist', async () => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route');
|
||||||
|
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(payload.error).toMatch(/User not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Route Tests
|
||||||
|
* Documentation: documentation/testing.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
const generateRefreshTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/jwt', () => ({
|
||||||
|
generateAccessToken: generateAccessTokenMock,
|
||||||
|
generateRefreshToken: generateRefreshTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/authRateLimit', () => ({
|
||||||
|
checkTokenLoginRateLimit: checkTokenLoginRateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeRequest(body: Record<string, unknown>, ip = '127.0.0.1') {
|
||||||
|
return {
|
||||||
|
headers: { get: vi.fn().mockReturnValue(ip) },
|
||||||
|
json: vi.fn().mockResolvedValue(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/auth/token/login', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
generateAccessTokenMock.mockReturnValue('access-token');
|
||||||
|
generateRefreshTokenMock.mockReturnValue('refresh-token');
|
||||||
|
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates user with a valid token', async () => {
|
||||||
|
prismaMock.user.findFirst.mockResolvedValueOnce({
|
||||||
|
id: 'u1',
|
||||||
|
plexId: 'plex-1',
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
plexEmail: 'test@example.com',
|
||||||
|
avatarUrl: null,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.accessToken).toBe('access-token');
|
||||||
|
expect(payload.refreshToken).toBe('refresh-token');
|
||||||
|
expect(payload.user.username).toBe('testuser');
|
||||||
|
expect(payload.user.email).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when token parameter is missing', async () => {
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({}) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toMatch(/Missing token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when token is invalid or user not found', async () => {
|
||||||
|
prismaMock.user.findFirst.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.error).toMatch(/Invalid token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 429 when rate limit is exceeded', async () => {
|
||||||
|
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 });
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_any' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(payload.error).toMatch(/Too many login attempts/);
|
||||||
|
expect(response.headers.get('Retry-After')).toBe('600');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user