mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: generate key one time
This commit is contained in:
@@ -9,6 +9,7 @@ wanaccess="yes"
|
|||||||
wanport="8443"
|
wanport="8443"
|
||||||
upnpEnabled="no"
|
upnpEnabled="no"
|
||||||
apikey="_______________________BIG_API_KEY_HERE_________________________"
|
apikey="_______________________BIG_API_KEY_HERE_________________________"
|
||||||
|
localApiKey="426b62b4d51e441fa97a93dfa1259920390a6eb61bd8675db0caa18dd0e414e9"
|
||||||
email="test@example.com"
|
email="test@example.com"
|
||||||
username="zspearmint"
|
username="zspearmint"
|
||||||
avatar="https://via.placeholder.com/200"
|
avatar="https://via.placeholder.com/200"
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"key": "73717ca0-8c15-40b9-bcca-8d85656d1438",
|
"key": "73717ca0-8c15-40b9-bcca-8d85656d1438",
|
||||||
"name": "Test API Key",
|
"name": "Test API Key",
|
||||||
"description": "Testing API key creation",
|
"description": "Testing API key creation",
|
||||||
"roles": ["guest", "upc"],
|
"roles": ["guest", "connect"],
|
||||||
"createdAt": "2024-10-29T19:59:12.569Z"
|
"createdAt": "2024-10-29T19:59:12.569Z"
|
||||||
}
|
}
|
||||||
|
|||||||
10
api/dev/keys/d166bf8b-3615-444a-8932-c460b2132ba3.json
Normal file
10
api/dev/keys/d166bf8b-3615-444a-8932-c460b2132ba3.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"createdAt": "2024-12-19T16:49:56.848Z",
|
||||||
|
"description": "API key for Connect user",
|
||||||
|
"id": "d166bf8b-3615-444a-8932-c460b2132ba3",
|
||||||
|
"key": "3a4e2332891e879d2ac8c3f25ef03a7b54f70b62cd6c5a08a86189cdd19ba203",
|
||||||
|
"name": "Connect",
|
||||||
|
"roles": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ wanaccess="yes"
|
|||||||
wanport="8443"
|
wanport="8443"
|
||||||
upnpEnabled="no"
|
upnpEnabled="no"
|
||||||
apikey="_______________________BIG_API_KEY_HERE_________________________"
|
apikey="_______________________BIG_API_KEY_HERE_________________________"
|
||||||
|
localApiKey="3a4e2332891e879d2ac8c3f25ef03a7b54f70b62cd6c5a08a86189cdd19ba203"
|
||||||
email="test@example.com"
|
email="test@example.com"
|
||||||
username="zspearmint"
|
username="zspearmint"
|
||||||
avatar="https://via.placeholder.com/200"
|
avatar="https://via.placeholder.com/200"
|
||||||
|
|||||||
@@ -1187,12 +1187,12 @@ export type RemoveRoleFromApiKeyInput = {
|
|||||||
|
|
||||||
/** Available resources for permissions */
|
/** Available resources for permissions */
|
||||||
export enum Resource {
|
export enum Resource {
|
||||||
API_KEY = 'api_key',
|
APIKEY = 'apikey',
|
||||||
ARRAY = 'array',
|
ARRAY = 'array',
|
||||||
CLOUD = 'cloud',
|
CLOUD = 'cloud',
|
||||||
CONFIG = 'config',
|
CONFIG = 'config',
|
||||||
CONNECT = 'connect',
|
CONNECT = 'connect',
|
||||||
CRASH_REPORTING_ENABLED = 'crash_reporting_enabled',
|
CONNECT__REMOTE_ACCESS = 'connect__remote_access',
|
||||||
CUSTOMIZATIONS = 'customizations',
|
CUSTOMIZATIONS = 'customizations',
|
||||||
DASHBOARD = 'dashboard',
|
DASHBOARD = 'dashboard',
|
||||||
DISK = 'disk',
|
DISK = 'disk',
|
||||||
@@ -1220,10 +1220,8 @@ export enum Resource {
|
|||||||
/** Available roles for API keys and users */
|
/** Available roles for API keys and users */
|
||||||
export enum Role {
|
export enum Role {
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
GUEST = 'guest',
|
CONNECT = 'connect',
|
||||||
MY_SERVERS = 'my_servers',
|
GUEST = 'guest'
|
||||||
NOTIFIER = 'notifier',
|
|
||||||
UPC = 'upc'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Server = {
|
export type Server = {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { decodeJwt } from 'jose';
|
import { decodeJwt } from 'jose';
|
||||||
|
|
||||||
import type { ConnectSignInInput } from '@app/graphql/generated/api/types';
|
import type { ConnectSignInInput } from '@app/graphql/generated/api/types';
|
||||||
import { Role } from '@app/graphql/generated/api/types';
|
|
||||||
import { getters, store } from '@app/store/index';
|
import { getters, store } from '@app/store/index';
|
||||||
import { loginUser } from '@app/store/modules/config';
|
import { loginUser } from '@app/store/modules/config';
|
||||||
import { FileLoadStatus } from '@app/store/types';
|
import { FileLoadStatus } from '@app/store/types';
|
||||||
@@ -30,11 +29,7 @@ export const connectSignIn = async (input: ConnectSignInInput): Promise<boolean>
|
|||||||
if (localApiKeyFromConfig == '') {
|
if (localApiKeyFromConfig == '') {
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
// Create local API key
|
// Create local API key
|
||||||
const localApiKey = await apiKeyService.create(
|
const localApiKey = await apiKeyService.createLocalConnectApiKey();
|
||||||
`LOCAL_KEY_${userInfo.preferred_username.toUpperCase()}`,
|
|
||||||
`Local API key for Connect user ${userInfo.email}`,
|
|
||||||
[Role.ADMIN]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!localApiKey?.key) {
|
if (!localApiKey?.key) {
|
||||||
throw new Error('Failed to create local API key');
|
throw new Error('Failed to create local API key');
|
||||||
@@ -60,4 +55,4 @@ export const connectSignIn = async (input: ConnectSignInInput): Promise<boolean>
|
|||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,48 +1,3 @@
|
|||||||
"""
|
|
||||||
Available resources for permissions
|
|
||||||
"""
|
|
||||||
enum Resource {
|
|
||||||
api_key
|
|
||||||
cloud
|
|
||||||
config
|
|
||||||
crash_reporting_enabled
|
|
||||||
customizations
|
|
||||||
disk
|
|
||||||
display
|
|
||||||
flash
|
|
||||||
info
|
|
||||||
logs
|
|
||||||
online
|
|
||||||
os
|
|
||||||
owner
|
|
||||||
permission
|
|
||||||
registration
|
|
||||||
servers
|
|
||||||
share
|
|
||||||
vars
|
|
||||||
connect
|
|
||||||
notifications
|
|
||||||
array
|
|
||||||
dashboard
|
|
||||||
docker
|
|
||||||
network
|
|
||||||
services
|
|
||||||
vms
|
|
||||||
me
|
|
||||||
welcome
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
Available roles for API keys and users
|
|
||||||
"""
|
|
||||||
enum Role {
|
|
||||||
admin
|
|
||||||
upc
|
|
||||||
my_servers
|
|
||||||
notifier
|
|
||||||
guest
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKey {
|
type ApiKey {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
|
|||||||
42
api/src/graphql/schema/types/auth/roles.graphql
Normal file
42
api/src/graphql/schema/types/auth/roles.graphql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Available resources for permissions
|
||||||
|
"""
|
||||||
|
enum Resource {
|
||||||
|
apikey
|
||||||
|
array
|
||||||
|
cloud
|
||||||
|
config
|
||||||
|
connect
|
||||||
|
connect__remote_access
|
||||||
|
customizations
|
||||||
|
dashboard
|
||||||
|
disk
|
||||||
|
display
|
||||||
|
docker
|
||||||
|
flash
|
||||||
|
info
|
||||||
|
logs
|
||||||
|
me
|
||||||
|
network
|
||||||
|
notifications
|
||||||
|
online
|
||||||
|
os
|
||||||
|
owner
|
||||||
|
permission
|
||||||
|
registration
|
||||||
|
servers
|
||||||
|
services
|
||||||
|
share
|
||||||
|
vars
|
||||||
|
vms
|
||||||
|
welcome
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
Available roles for API keys and users
|
||||||
|
"""
|
||||||
|
enum Role {
|
||||||
|
admin
|
||||||
|
connect
|
||||||
|
guest
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch';
|
|||||||
import { StateManager } from '@app/store/watch/state-watch';
|
import { StateManager } from '@app/store/watch/state-watch';
|
||||||
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
|
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
|
||||||
import { bootstrapNestServer } from '@app/unraid-api/main';
|
import { bootstrapNestServer } from '@app/unraid-api/main';
|
||||||
|
import { createLocalApiKeyForConnectIfNecessary } from '@app/mothership/utils/create-local-connect-api-key';
|
||||||
|
|
||||||
import { setupNewMothershipSubscription } from './mothership/subscribe-to-mothership';
|
import { setupNewMothershipSubscription } from './mothership/subscribe-to-mothership';
|
||||||
|
|
||||||
@@ -87,6 +88,8 @@ try {
|
|||||||
// Start listening to dynamix config file changes
|
// Start listening to dynamix config file changes
|
||||||
setupDynamixConfigWatch();
|
setupDynamixConfigWatch();
|
||||||
|
|
||||||
|
await createLocalApiKeyForConnectIfNecessary();
|
||||||
|
|
||||||
// Disabled until we need the access token to work
|
// Disabled until we need the access token to work
|
||||||
// TokenRefresh.init();
|
// TokenRefresh.init();
|
||||||
|
|
||||||
|
|||||||
34
api/src/mothership/utils/create-local-connect-api-key.ts
Normal file
34
api/src/mothership/utils/create-local-connect-api-key.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { minigraphLogger } from '@app/core/log';
|
||||||
|
import { getters, store } from '@app/store/index';
|
||||||
|
import { updateUserConfig } from '@app/store/modules/config';
|
||||||
|
import { FileLoadStatus } from '@app/store/types';
|
||||||
|
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
||||||
|
|
||||||
|
export const createLocalApiKeyForConnectIfNecessary = async () => {
|
||||||
|
if (getters.config().status !== FileLoadStatus.LOADED) {
|
||||||
|
minigraphLogger.error('Config file not loaded, cannot create local API key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remote } = getters.config();
|
||||||
|
const service = new ApiKeyService();
|
||||||
|
// If the remote API Key is set and the local key is either not set or not found on disk, create a key
|
||||||
|
if (remote.apikey && (!remote.localApiKey || !(await service.findById(remote.localApiKey)))) {
|
||||||
|
minigraphLogger.debug('Creating local API key for Connect');
|
||||||
|
// Create local API key
|
||||||
|
const apiKeyService = new ApiKeyService();
|
||||||
|
const localApiKey = await apiKeyService.createLocalConnectApiKey();
|
||||||
|
|
||||||
|
if (localApiKey?.key) {
|
||||||
|
store.dispatch(
|
||||||
|
updateUserConfig({
|
||||||
|
remote: {
|
||||||
|
localApiKey: localApiKey.key,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to create local API key - no key returned');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
import type { TypedAddListener, TypedStartListening } from '@reduxjs/toolkit';
|
import type { TypedAddListener, TypedStartListening } from '@reduxjs/toolkit';
|
||||||
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
@@ -6,17 +8,12 @@ import { enableArrayEventListener } from '@app/store/listeners/array-event-liste
|
|||||||
import { enableConfigFileListener } from '@app/store/listeners/config-listener';
|
import { enableConfigFileListener } from '@app/store/listeners/config-listener';
|
||||||
import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener';
|
import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener';
|
||||||
import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener';
|
import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener';
|
||||||
|
import { enableNotificationPathListener } from '@app/store/listeners/notification-path-listener';
|
||||||
import { enableServerStateListener } from '@app/store/listeners/server-state-listener';
|
import { enableServerStateListener } from '@app/store/listeners/server-state-listener';
|
||||||
import { enableUpnpListener } from '@app/store/listeners/upnp-listener';
|
import { enableUpnpListener } from '@app/store/listeners/upnp-listener';
|
||||||
import { enableVersionListener } from '@app/store/listeners/version-listener';
|
import { enableVersionListener } from '@app/store/listeners/version-listener';
|
||||||
import { enableWanAccessChangeListener } from '@app/store/listeners/wan-access-change-listener';
|
import { enableWanAccessChangeListener } from '@app/store/listeners/wan-access-change-listener';
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
|
|
||||||
import { enableNotificationPathListener } from '@app/store/listeners/notification-path-listener';
|
|
||||||
|
|
||||||
import { enableLocalApiKeyListener } from './local-api-key-listener';
|
|
||||||
|
|
||||||
export const listenerMiddleware = createListenerMiddleware();
|
export const listenerMiddleware = createListenerMiddleware();
|
||||||
|
|
||||||
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
|
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
|
||||||
@@ -29,7 +26,6 @@ export const addAppListener = addListener as TypedAddListener<RootState, AppDisp
|
|||||||
|
|
||||||
export const startMiddlewareListeners = () => {
|
export const startMiddlewareListeners = () => {
|
||||||
// Begin listening for events
|
// Begin listening for events
|
||||||
enableLocalApiKeyListener();
|
|
||||||
enableMothershipJobsListener();
|
enableMothershipJobsListener();
|
||||||
enableConfigFileListener('flash')();
|
enableConfigFileListener('flash')();
|
||||||
enableConfigFileListener('memory')();
|
enableConfigFileListener('memory')();
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { logger } from '@app/core/log';
|
|
||||||
import { Role } from '@app/graphql/generated/api/types';
|
|
||||||
import { getters } from '@app/store/index';
|
|
||||||
import { startAppListening } from '@app/store/listeners/listener-middleware';
|
|
||||||
import { updateUserConfig } from '@app/store/modules/config';
|
|
||||||
import { FileLoadStatus } from '@app/store/types';
|
|
||||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
|
|
||||||
|
|
||||||
export const enableLocalApiKeyListener = () =>
|
|
||||||
startAppListening({
|
|
||||||
predicate(_, currentState) {
|
|
||||||
return (
|
|
||||||
currentState.config.status === FileLoadStatus.LOADED &&
|
|
||||||
currentState.config.remote.apikey !== '' &&
|
|
||||||
currentState.config.remote.localApiKey === ''
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async effect(_, { dispatch }) {
|
|
||||||
try {
|
|
||||||
const { remote } = getters.config();
|
|
||||||
const { apikey, username } = remote;
|
|
||||||
// Validate the API key with the key server
|
|
||||||
const apiKeyService = new ApiKeyService();
|
|
||||||
// Create local API key
|
|
||||||
const localApiKey = await apiKeyService.create(
|
|
||||||
`LOCAL_KEY_${(username as string).toUpperCase()}`,
|
|
||||||
`Local API key for Connect user ${username}`,
|
|
||||||
[Role.ADMIN]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (localApiKey?.key) {
|
|
||||||
dispatch(
|
|
||||||
updateUserConfig({
|
|
||||||
remote: {
|
|
||||||
localApiKey: localApiKey.key,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to create local API key - no key returned');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create local API key', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -3,20 +3,29 @@ import crypto from 'crypto';
|
|||||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { ensureDir } from 'fs-extra';
|
import { ensureDir } from 'fs-extra';
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
|
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
|
||||||
import { ApiKey, ApiKeyWithSecret, Role, UserAccount } from '@app/graphql/generated/api/types';
|
import { ApiKey, ApiKeyWithSecret, Role, UserAccount } from '@app/graphql/generated/api/types';
|
||||||
import { getters } from '@app/store';
|
import { getters } from '@app/store';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeyService implements OnModuleInit {
|
export class ApiKeyService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ApiKeyService.name);
|
private readonly logger = new Logger(ApiKeyService.name);
|
||||||
protected readonly basePath: string;
|
protected readonly basePath: string;
|
||||||
protected readonly keyFile: (id: string) => string;
|
protected readonly keyFile: (id: string) => string;
|
||||||
|
protected memoryApiKeys = new Map<string, ApiKeyWithSecret>();
|
||||||
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
|
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -34,6 +43,8 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
throw new GraphQLError('Failed to initialize API key storage');
|
throw new GraphQLError('Failed to initialize API key storage');
|
||||||
}
|
}
|
||||||
this.logger.verbose(`Using API key base path: ${this.basePath}`);
|
this.logger.verbose(`Using API key base path: ${this.basePath}`);
|
||||||
|
|
||||||
|
// @todo setup file watch to reload keys
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -41,13 +52,18 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeName(name: string): string {
|
private sanitizeName(name: string): string {
|
||||||
return name.replace(/[^a-zA-Z0-9-_]/g, '_').toUpperCase();
|
if (/^[\p{L}\p{N} ]+$/u.test(name)) {
|
||||||
|
return name;
|
||||||
|
} else {
|
||||||
|
throw new GraphQLError('API key name must be alphanumeric and can only contain spaces');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
name: string,
|
name: string,
|
||||||
description: string | undefined,
|
description: string | undefined,
|
||||||
roles: Role[]
|
roles: Role[],
|
||||||
|
overwrite: boolean = false
|
||||||
): Promise<ApiKeyWithSecret> {
|
): Promise<ApiKeyWithSecret> {
|
||||||
const trimmedName = name?.trim();
|
const trimmedName = name?.trim();
|
||||||
const sanitizedName = this.sanitizeName(trimmedName);
|
const sanitizedName = this.sanitizeName(trimmedName);
|
||||||
@@ -63,19 +79,24 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
||||||
throw new GraphQLError('Invalid role specified');
|
throw new GraphQLError('Invalid role specified');
|
||||||
}
|
}
|
||||||
|
const apiKey: Partial<ApiKeyWithSecret> = (await this.findByField('name', sanitizedName)) ?? {
|
||||||
const apiKey: ApiKeyWithSecret = {
|
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
key: this.generateApiKey(),
|
key: this.generateApiKey(),
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
description,
|
|
||||||
roles,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.saveApiKey(apiKey);
|
if (!overwrite && apiKey.createdAt) {
|
||||||
|
throw new GraphQLError('API key name already exists, use overwrite flag to update');
|
||||||
|
}
|
||||||
|
|
||||||
return apiKey;
|
apiKey.description = description;
|
||||||
|
apiKey.roles = roles;
|
||||||
|
// Update createdAt date
|
||||||
|
apiKey.createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await this.saveApiKey(apiKey as ApiKeyWithSecret);
|
||||||
|
|
||||||
|
return apiKey as ApiKeyWithSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<ApiKey[]> {
|
async findAll(): Promise<ApiKey[]> {
|
||||||
@@ -162,12 +183,11 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByKey(key: string): Promise<ApiKeyWithSecret | null> {
|
async findByField(field: keyof ApiKeyWithSecret, value: string): Promise<ApiKeyWithSecret | null> {
|
||||||
if (!key) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await readdir(this.basePath);
|
const files = await readdir(this.basePath);
|
||||||
const keyBuffer1 = Buffer.from(key);
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.endsWith('.json')) continue;
|
if (!file.endsWith('.json')) continue;
|
||||||
@@ -187,14 +207,14 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = ApiKeyWithSecretSchema().parse(parsedContent);
|
const apiKey = ApiKeyWithSecretSchema().parse(parsedContent);
|
||||||
const keyBuffer2 = Buffer.from(apiKey.key);
|
|
||||||
|
|
||||||
if (
|
if (field === 'key') {
|
||||||
keyBuffer1.length === keyBuffer2.length &&
|
if (crypto.timingSafeEqual(Buffer.from(apiKey[field]), Buffer.from(value))) {
|
||||||
crypto.timingSafeEqual(keyBuffer1, keyBuffer2)
|
apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST);
|
||||||
) {
|
return apiKey;
|
||||||
|
}
|
||||||
|
} else if (apiKey[field] === value) {
|
||||||
apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST);
|
apiKey.roles = apiKey.roles.map((role) => role || Role.GUEST);
|
||||||
|
|
||||||
return apiKey;
|
return apiKey;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -203,7 +223,6 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Error processing API key file ${file}: ${error}`);
|
this.logger.error(`Error processing API key file ${file}: ${error}`);
|
||||||
throw new GraphQLError('Authentication system error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +237,10 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByKey(key: string): Promise<ApiKeyWithSecret | null> {
|
||||||
|
return this.findByField('key', key);
|
||||||
|
}
|
||||||
|
|
||||||
async findOneByKey(apiKey: string): Promise<UserAccount | null> {
|
async findOneByKey(apiKey: string): Promise<UserAccount | null> {
|
||||||
try {
|
try {
|
||||||
const key = await this.findByKey(apiKey);
|
const key = await this.findByKey(apiKey);
|
||||||
@@ -247,11 +270,22 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
return crypto.randomBytes(32).toString('hex');
|
return crypto.randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret> {
|
||||||
|
return await this.create('Connect', 'API key for Connect user', [Role.ADMIN], true);
|
||||||
|
}
|
||||||
|
|
||||||
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const validatedApiKey = ApiKeyWithSecretSchema().parse(apiKey);
|
const validatedApiKey = ApiKeyWithSecretSchema().parse(apiKey);
|
||||||
|
|
||||||
await writeFile(this.keyFile(validatedApiKey.id), JSON.stringify(validatedApiKey, null, 2));
|
const sortedApiKey = Object.keys(validatedApiKey)
|
||||||
|
.sort()
|
||||||
|
.reduce((acc, key) => {
|
||||||
|
acc[key] = validatedApiKey[key];
|
||||||
|
return acc;
|
||||||
|
}, {} as ApiKeyWithSecret);
|
||||||
|
|
||||||
|
await writeFile(this.keyFile(validatedApiKey.id), JSON.stringify(sortedApiKey, null, 2));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
this.logger.error('Invalid API key structure', error.errors);
|
this.logger.error('Invalid API key structure', error.errors);
|
||||||
@@ -270,4 +304,4 @@ export class ApiKeyService implements OnModuleInit {
|
|||||||
keyFile: this.keyFile,
|
keyFile: this.keyFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,63 +6,14 @@ export const BASE_POLICY = `
|
|||||||
# Admin permissions
|
# Admin permissions
|
||||||
p, ${Role.ADMIN}, *, *, *
|
p, ${Role.ADMIN}, *, *, *
|
||||||
|
|
||||||
# UPC permissions for API keys
|
# Connect Permissions
|
||||||
p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.CREATE_ANY}
|
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}
|
||||||
p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.UPDATE_ANY}
|
p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY}
|
||||||
|
|
||||||
# UPC permissions
|
|
||||||
p, ${Role.UPC}, ${Resource.CLOUD}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.CONFIG}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, crash-reporting-enabled, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.CUSTOMIZATIONS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.DISK}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.DISPLAY}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.FLASH}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.INFO}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.LOGS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.OS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.OWNER}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.REGISTRATION}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.SERVERS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.VARS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.CONFIG}, ${AuthAction.UPDATE_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.UPDATE_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.CONNECT}, ${AuthAction.UPDATE_OWN}
|
|
||||||
p, ${Role.UPC}, ${Resource.NOTIFICATIONS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.UPC}, ${Resource.NOTIFICATIONS}, ${AuthAction.UPDATE_ANY}
|
|
||||||
|
|
||||||
# My Servers permissions
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.CONFIG}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.CONNECT}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, connect/dynamic-remote-access, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, connect/dynamic-remote-access, ${AuthAction.UPDATE_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.CUSTOMIZATIONS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.DASHBOARD}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.DISPLAY}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, docker/container, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.INFO}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.LOGS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.NETWORK}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.NOTIFICATIONS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.SERVICES}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.VARS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, ${Resource.VMS}, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, vms/domain, ${AuthAction.READ_ANY}
|
|
||||||
p, ${Role.MY_SERVERS}, unraid-version, ${AuthAction.READ_ANY}
|
|
||||||
|
|
||||||
# Notifier permissions
|
|
||||||
p, ${Role.NOTIFIER}, ${Resource.NOTIFICATIONS}, ${AuthAction.CREATE_OWN}
|
|
||||||
|
|
||||||
# Guest permissions
|
# Guest permissions
|
||||||
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
|
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
|
||||||
p, ${Role.GUEST}, ${Resource.WELCOME}, ${AuthAction.READ_ANY}
|
|
||||||
|
|
||||||
# Role inheritance
|
# Role inheritance
|
||||||
g, ${Role.ADMIN}, ${Role.GUEST}
|
g, ${Role.ADMIN}, ${Role.GUEST}
|
||||||
g, ${Role.UPC}, ${Role.GUEST}
|
g, ${Role.CONNECT}, ${Role.GUEST}
|
||||||
g, ${Role.MY_SERVERS}, ${Role.GUEST}
|
|
||||||
g, ${Role.NOTIFIER}, ${Role.GUEST}
|
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
DynamicRemoteAccessStatus,
|
DynamicRemoteAccessStatus,
|
||||||
EnableDynamicRemoteAccessInput,
|
EnableDynamicRemoteAccessInput,
|
||||||
} from '@app/graphql/generated/api/types';
|
} from '@app/graphql/generated/api/types';
|
||||||
import { ConnectResolvers, DynamicRemoteAccessType } from '@app/graphql/generated/api/types';
|
import { ConnectResolvers, DynamicRemoteAccessType, Resource } from '@app/graphql/generated/api/types';
|
||||||
import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller';
|
import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller';
|
||||||
import { store } from '@app/store/index';
|
import { store } from '@app/store/index';
|
||||||
import { setAllowedRemoteAccessUrl } from '@app/store/modules/dynamic-remote-access';
|
import { setAllowedRemoteAccessUrl } from '@app/store/modules/dynamic-remote-access';
|
||||||
@@ -20,7 +20,7 @@ export class ConnectResolver implements ConnectResolvers {
|
|||||||
@Query('connect')
|
@Query('connect')
|
||||||
@UsePermissions({
|
@UsePermissions({
|
||||||
action: AuthActionVerb.READ,
|
action: AuthActionVerb.READ,
|
||||||
resource: 'connect/dynamic-remote-access',
|
resource: Resource.CONNECT,
|
||||||
possession: AuthPossession.ANY,
|
possession: AuthPossession.ANY,
|
||||||
})
|
})
|
||||||
public connect() {
|
public connect() {
|
||||||
@@ -46,7 +46,7 @@ export class ConnectResolver implements ConnectResolvers {
|
|||||||
@Mutation()
|
@Mutation()
|
||||||
@UsePermissions({
|
@UsePermissions({
|
||||||
action: AuthActionVerb.UPDATE,
|
action: AuthActionVerb.UPDATE,
|
||||||
resource: 'connect/dynamic-remote-access',
|
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||||
possession: AuthPossession.ANY,
|
possession: AuthPossession.ANY,
|
||||||
})
|
})
|
||||||
public async enableDynamicRemoteAccess(
|
public async enableDynamicRemoteAccess(
|
||||||
|
|||||||
Reference in New Issue
Block a user