Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ PATHS_LOG_BASE=./dev/log # Where we store logs
PATHS_LOGS_FILE=./dev/log/graphql-api.log
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
PATHS_OIDC_JSON=./dev/configs/oidc.local.json
PATHS_LOCAL_SESSION_FILE=./dev/local-session
ENVIRONMENT="development"
NODE_ENV="development"
PORT="3001"
Expand Down
1 change: 1 addition & 0 deletions api/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ PATHS_CONFIG_MODULES=./dev/configs
PATHS_ACTIVATION_BASE=./dev/activation
PATHS_PASSWD=./dev/passwd
PATHS_LOGS_FILE=./dev/log/graphql-api.log
PATHS_LOCAL_SESSION_FILE=./dev/local-session
PORT=5000
NODE_ENV="test"
2 changes: 2 additions & 0 deletions api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ dev/connectStatus.json
dev/configs/*
# local status - doesn't need to be tracked
dev/connectStatus.json
# mock local session file
dev/local-session

# local OIDC config for testing - contains secrets
dev/configs/oidc.local.json
11 changes: 0 additions & 11 deletions api/dev/keys/b5b4aa3d-8e40-4c92-bc40-d50182071886.json

This file was deleted.

11 changes: 0 additions & 11 deletions api/dev/keys/fc91da7b-0284-46f4-9018-55aa9759fba9.json

This file was deleted.

3 changes: 3 additions & 0 deletions api/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,6 @@ export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-

export const PATHS_CONFIG_MODULES =
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';

export const PATHS_LOCAL_SESSION_FILE =
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
17 changes: 14 additions & 3 deletions api/src/unraid-api/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js'
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
import { LocalSessionLifecycleService } from '@app/unraid-api/auth/local-session-lifecycle.service.js';
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
import { LocalSessionStrategy } from '@app/unraid-api/auth/local-session.strategy.js';
import { getRequest } from '@app/utils.js';

@Module({
imports: [
PassportModule.register({
defaultStrategy: [ServerHeaderStrategy.key, UserCookieStrategy.key],
defaultStrategy: [
ServerHeaderStrategy.key,
LocalSessionStrategy.key,
UserCookieStrategy.key,
],
}),
CasbinModule,
AuthZModule.register({
Expand Down Expand Up @@ -51,10 +57,12 @@ import { getRequest } from '@app/utils.js';
providers: [
AuthService,
ApiKeyService,
AdminKeyService,
ServerHeaderStrategy,
LocalSessionStrategy,
UserCookieStrategy,
CookieService,
LocalSessionService,
LocalSessionLifecycleService,
{
provide: SESSION_COOKIE_CONFIG,
useValue: CookieService.defaultOpts(),
Expand All @@ -65,8 +73,11 @@ import { getRequest } from '@app/utils.js';
ApiKeyService,
PassportModule,
ServerHeaderStrategy,
LocalSessionStrategy,
UserCookieStrategy,
CookieService,
LocalSessionService,
LocalSessionLifecycleService,
AuthZModule,
],
})
Expand Down
7 changes: 6 additions & 1 deletion api/src/unraid-api/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
Expand All @@ -17,6 +18,7 @@ describe('AuthService', () => {
let apiKeyService: ApiKeyService;
let authzService: AuthZService;
let cookieService: CookieService;
let localSessionService: LocalSessionService;

const mockApiKey: ApiKey = {
id: 'test-api-id',
Expand Down Expand Up @@ -55,7 +57,10 @@ describe('AuthService', () => {
apiKeyService = new ApiKeyService();
authzService = new AuthZService(enforcer);
cookieService = new CookieService();
authService = new AuthService(cookieService, apiKeyService, authzService);
localSessionService = {
validateLocalSession: vi.fn(),
} as any;
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
});

afterEach(() => {
Expand Down
51 changes: 49 additions & 2 deletions api/src/unraid-api/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';

import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import {
Expand All @@ -12,6 +13,7 @@ import { AuthZService } from 'nest-authz';
import { getters } from '@app/store/index.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
Expand All @@ -24,6 +26,7 @@ export class AuthService {
constructor(
private cookieService: CookieService,
private apiKeyService: ApiKeyService,
private localSessionService: LocalSessionService,
private authzService: AuthZService
) {}

Expand Down Expand Up @@ -89,6 +92,30 @@ export class AuthService {
}
}

async validateLocalSession(localSessionToken: string): Promise<UserAccount> {
try {
const isValid = await this.localSessionService.validateLocalSession(localSessionToken);

if (!isValid) {
throw new UnauthorizedException('Invalid local session token');
}

// Local session has admin privileges
const user = await this.getLocalSessionUser();

// Sync the user's roles before checking them
await this.syncUserRoles(user.id, user.roles);

// Now get the updated roles
const existingRoles = await this.authzService.getRolesForUser(user.id);
this.logger.debug(`Local session user ${user.id} has roles: ${existingRoles}`);

return user;
} catch (error: unknown) {
handleAuthError(this.logger, 'Failed to validate local session', error);
}
}

public async syncApiKeyRoles(apiKeyId: string, roles: string[]): Promise<void> {
try {
// Get existing roles and convert to Set
Expand Down Expand Up @@ -254,7 +281,10 @@ export class AuthService {
}

public validateCsrfToken(token?: string): boolean {
return Boolean(token) && token === getters.emhttp().var.csrfToken;
if (!token) return false;
const csrfToken = getters.emhttp().var.csrfToken;
if (!csrfToken) return false;
return timingSafeEqual(Buffer.from(token, 'utf-8'), Buffer.from(csrfToken, 'utf-8'));
}

/**
Expand Down Expand Up @@ -321,7 +351,7 @@ export class AuthService {
* @returns a service account that represents the user session (i.e. a webgui user).
*/
async getSessionUser(): Promise<UserAccount> {
this.logger.debug('getSessionUser called!');
this.logger.verbose('getSessionUser called!');
return {
id: '-1',
description: 'Session receives administrator permissions',
Expand All @@ -330,4 +360,21 @@ export class AuthService {
permissions: [],
};
}

/**
* Returns a user object representing a local session.
* Note: Does NOT perform validation.
*
* @returns a service account that represents the local session user (i.e. CLI/system operations).
*/
async getLocalSessionUser(): Promise<UserAccount> {
this.logger.verbose('getLocalSessionUser called!');
return {
id: '-2',
description: 'Local session receives administrator permissions for CLI/system operations',
name: 'local-admin',
roles: [Role.ADMIN],
permissions: [],
};
}
}
3 changes: 2 additions & 1 deletion api/src/unraid-api/auth/authentication.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { FastifyRequest } from '@app/unraid-api/types/fastify.js';
import { apiLogger } from '@app/core/log.js';
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
import { LocalSessionStrategy } from '@app/unraid-api/auth/local-session.strategy.js';
import { IS_PUBLIC_ENDPOINT_KEY } from '@app/unraid-api/auth/public.decorator.js';

/**
Expand All @@ -37,7 +38,7 @@ type GraphQLContext =

@Injectable()
export class AuthenticationGuard
extends AuthGuard([ServerHeaderStrategy.key, UserCookieStrategy.key])
extends AuthGuard([ServerHeaderStrategy.key, LocalSessionStrategy.key, UserCookieStrategy.key])
implements CanActivate
{
protected logger = new Logger(AuthenticationGuard.name);
Expand Down
39 changes: 36 additions & 3 deletions api/src/unraid-api/auth/cookie.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { readFile } from 'fs/promises';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';

import { fileExists } from '@app/core/utils/files/file-exists.js';
Expand All @@ -9,7 +9,7 @@ import { batchProcess } from '@app/utils.js';
/** token for dependency injection of a session cookie options object */
export const SESSION_COOKIE_CONFIG = 'SESSION_COOKIE_CONFIG';

type SessionCookieConfig = {
export type SessionCookieConfig = {
namePrefix: string;
sessionDir: string;
secure: boolean;
Expand Down Expand Up @@ -68,13 +68,17 @@ export class CookieService {
}
try {
const sessionData = await readFile(sessionFile, 'ascii');
return sessionData.includes('unraid_login') && sessionData.includes('unraid_user');
return this.isSessionValid(sessionData);
} catch (e) {
this.logger.error(e, 'Error reading session file');
return false;
}
}

private isSessionValid(sessionData: string): boolean {
return sessionData.includes('unraid_login') && sessionData.includes('unraid_user');
}

/**
* Given a session id, returns the full path to the session file on disk.
*
Expand All @@ -91,4 +95,33 @@ export class CookieService {
const sanitizedSessionId = sessionId.replace(/[^a-zA-Z0-9]/g, '');
return join(this.opts.sessionDir, `sess_${sanitizedSessionId}`);
}

/**
* Returns the active session id, if any.
* @returns the active session id, if any, or null if no active session is found.
*/
async getActiveSession(): Promise<string | null> {
let sessionFiles: string[] = [];
try {
sessionFiles = await readdir(this.opts.sessionDir);
} catch (e) {
this.logger.warn(e, 'Error reading session directory');
return null;
}
for (const sessionFile of sessionFiles) {
if (!sessionFile.startsWith('sess_')) {
continue;
}
try {
const sessionData = await readFile(join(this.opts.sessionDir, sessionFile), 'ascii');
if (this.isSessionValid(sessionData)) {
return sessionFile.replace('sess_', '');
}
} catch {
// Ignore unreadable files and continue scanning
continue;
}
}
return null;
}
}
21 changes: 21 additions & 0 deletions api/src/unraid-api/auth/local-session-lifecycle.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Injectable, OnModuleInit } from '@nestjs/common';

import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';

/**
* Service for managing the lifecycle of the local session.
*
* Used for tying the local session's lifecycle to the API's life, rather
* than the LocalSessionService's lifecycle, since it may also be used by
* other applications, like the CLI.
*
* This service is only used in the API, and not in the CLI.
*/
@Injectable()
export class LocalSessionLifecycleService implements OnModuleInit {
constructor(private readonly localSessionService: LocalSessionService) {}

async onModuleInit() {
await this.localSessionService.generateLocalSession();
}
}
Loading
Loading