feat: generate key one time

This commit is contained in:
Eli Bosley
2024-12-19 11:50:43 -05:00
parent f0f5a3057a
commit 143515560c
15 changed files with 163 additions and 189 deletions

View File

@@ -9,6 +9,7 @@ wanaccess="yes"
wanport="8443"
upnpEnabled="no"
apikey="_______________________BIG_API_KEY_HERE_________________________"
localApiKey="426b62b4d51e441fa97a93dfa1259920390a6eb61bd8675db0caa18dd0e414e9"
email="test@example.com"
username="zspearmint"
avatar="https://via.placeholder.com/200"

View File

@@ -3,6 +3,6 @@
"key": "73717ca0-8c15-40b9-bcca-8d85656d1438",
"name": "Test API Key",
"description": "Testing API key creation",
"roles": ["guest", "upc"],
"roles": ["guest", "connect"],
"createdAt": "2024-10-29T19:59:12.569Z"
}

View 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"
]
}

View File

@@ -9,6 +9,7 @@ wanaccess="yes"
wanport="8443"
upnpEnabled="no"
apikey="_______________________BIG_API_KEY_HERE_________________________"
localApiKey="3a4e2332891e879d2ac8c3f25ef03a7b54f70b62cd6c5a08a86189cdd19ba203"
email="test@example.com"
username="zspearmint"
avatar="https://via.placeholder.com/200"

View File

@@ -1187,12 +1187,12 @@ export type RemoveRoleFromApiKeyInput = {
/** Available resources for permissions */
export enum Resource {
API_KEY = 'api_key',
APIKEY = 'apikey',
ARRAY = 'array',
CLOUD = 'cloud',
CONFIG = 'config',
CONNECT = 'connect',
CRASH_REPORTING_ENABLED = 'crash_reporting_enabled',
CONNECT__REMOTE_ACCESS = 'connect__remote_access',
CUSTOMIZATIONS = 'customizations',
DASHBOARD = 'dashboard',
DISK = 'disk',
@@ -1220,10 +1220,8 @@ export enum Resource {
/** Available roles for API keys and users */
export enum Role {
ADMIN = 'admin',
GUEST = 'guest',
MY_SERVERS = 'my_servers',
NOTIFIER = 'notifier',
UPC = 'upc'
CONNECT = 'connect',
GUEST = 'guest'
}
export type Server = {

View File

@@ -1,7 +1,6 @@
import { decodeJwt } from 'jose';
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 { loginUser } from '@app/store/modules/config';
import { FileLoadStatus } from '@app/store/types';
@@ -30,11 +29,7 @@ export const connectSignIn = async (input: ConnectSignInInput): Promise<boolean>
if (localApiKeyFromConfig == '') {
const apiKeyService = new ApiKeyService();
// Create local API key
const localApiKey = await apiKeyService.create(
`LOCAL_KEY_${userInfo.preferred_username.toUpperCase()}`,
`Local API key for Connect user ${userInfo.email}`,
[Role.ADMIN]
);
const localApiKey = await apiKeyService.createLocalConnectApiKey();
if (!localApiKey?.key) {
throw new Error('Failed to create local API key');
@@ -60,4 +55,4 @@ export const connectSignIn = async (input: ConnectSignInInput): Promise<boolean>
} else {
return false;
}
};
};

View File

@@ -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 {
id: ID!
name: String!

View 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
}

View File

@@ -31,6 +31,7 @@ import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch';
import { StateManager } from '@app/store/watch/state-watch';
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
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';
@@ -87,6 +88,8 @@ try {
// Start listening to dynamix config file changes
setupDynamixConfigWatch();
await createLocalApiKeyForConnectIfNecessary();
// Disabled until we need the access token to work
// TokenRefresh.init();

View 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');
}
}
};

View File

@@ -1,3 +1,5 @@
import 'reflect-metadata';
import type { TypedAddListener, TypedStartListening } 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 { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-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 { enableUpnpListener } from '@app/store/listeners/upnp-listener';
import { enableVersionListener } from '@app/store/listeners/version-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 type AppStartListening = TypedStartListening<RootState, AppDispatch>;
@@ -29,7 +26,6 @@ export const addAppListener = addListener as TypedAddListener<RootState, AppDisp
export const startMiddlewareListeners = () => {
// Begin listening for events
enableLocalApiKeyListener();
enableMothershipJobsListener();
enableConfigFileListener('flash')();
enableConfigFileListener('memory')();

View File

@@ -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);
}
},
});

View File

@@ -3,20 +3,29 @@ import crypto from 'crypto';
import { readdir, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { ensureDir } from 'fs-extra';
import { GraphQLError } from 'graphql';
import { v4 as uuidv4 } from 'uuid';
import { ZodError } from 'zod';
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
import { ApiKey, ApiKeyWithSecret, Role, UserAccount } from '@app/graphql/generated/api/types';
import { getters } from '@app/store';
@Injectable()
export class ApiKeyService implements OnModuleInit {
private readonly logger = new Logger(ApiKeyService.name);
protected readonly basePath: string;
protected readonly keyFile: (id: string) => string;
protected memoryApiKeys = new Map<string, ApiKeyWithSecret>();
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
constructor() {
@@ -34,6 +43,8 @@ export class ApiKeyService implements OnModuleInit {
throw new GraphQLError('Failed to initialize API key storage');
}
this.logger.verbose(`Using API key base path: ${this.basePath}`);
// @todo setup file watch to reload keys
}
async onModuleInit() {
@@ -41,13 +52,18 @@ export class ApiKeyService implements OnModuleInit {
}
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(
name: string,
description: string | undefined,
roles: Role[]
roles: Role[],
overwrite: boolean = false
): Promise<ApiKeyWithSecret> {
const trimmedName = name?.trim();
const sanitizedName = this.sanitizeName(trimmedName);
@@ -63,19 +79,24 @@ export class ApiKeyService implements OnModuleInit {
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
throw new GraphQLError('Invalid role specified');
}
const apiKey: ApiKeyWithSecret = {
const apiKey: Partial<ApiKeyWithSecret> = (await this.findByField('name', sanitizedName)) ?? {
id: uuidv4(),
key: this.generateApiKey(),
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[]> {
@@ -162,12 +183,11 @@ export class ApiKeyService implements OnModuleInit {
}
}
async findByKey(key: string): Promise<ApiKeyWithSecret | null> {
if (!key) return null;
async findByField(field: keyof ApiKeyWithSecret, value: string): Promise<ApiKeyWithSecret | null> {
if (!value) return null;
try {
const files = await readdir(this.basePath);
const keyBuffer1 = Buffer.from(key);
for (const file of files) {
if (!file.endsWith('.json')) continue;
@@ -187,14 +207,14 @@ export class ApiKeyService implements OnModuleInit {
}
const apiKey = ApiKeyWithSecretSchema().parse(parsedContent);
const keyBuffer2 = Buffer.from(apiKey.key);
if (
keyBuffer1.length === keyBuffer2.length &&
crypto.timingSafeEqual(keyBuffer1, keyBuffer2)
) {
if (field === 'key') {
if (crypto.timingSafeEqual(Buffer.from(apiKey[field]), Buffer.from(value))) {
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);
return apiKey;
}
} catch (error) {
@@ -203,7 +223,6 @@ export class ApiKeyService implements OnModuleInit {
}
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> {
try {
const key = await this.findByKey(apiKey);
@@ -247,11 +270,22 @@ export class ApiKeyService implements OnModuleInit {
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> {
try {
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) {
if (error instanceof ZodError) {
this.logger.error('Invalid API key structure', error.errors);
@@ -270,4 +304,4 @@ export class ApiKeyService implements OnModuleInit {
keyFile: this.keyFile,
};
}
}
}

View File

@@ -6,63 +6,14 @@ export const BASE_POLICY = `
# Admin permissions
p, ${Role.ADMIN}, *, *, *
# UPC permissions for API keys
p, ${Role.UPC}, ${Resource.API_KEY}, ${AuthAction.CREATE_ANY}
p, ${Role.UPC}, ${Resource.API_KEY}, ${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}
# Connect Permissions
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}
p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY}
# Guest permissions
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
p, ${Role.GUEST}, ${Resource.WELCOME}, ${AuthAction.READ_ANY}
# Role inheritance
g, ${Role.ADMIN}, ${Role.GUEST}
g, ${Role.UPC}, ${Role.GUEST}
g, ${Role.MY_SERVERS}, ${Role.GUEST}
g, ${Role.NOTIFIER}, ${Role.GUEST}
g, ${Role.CONNECT}, ${Role.GUEST}
`;

View File

@@ -8,7 +8,7 @@ import type {
DynamicRemoteAccessStatus,
EnableDynamicRemoteAccessInput,
} 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 { store } from '@app/store/index';
import { setAllowedRemoteAccessUrl } from '@app/store/modules/dynamic-remote-access';
@@ -20,7 +20,7 @@ export class ConnectResolver implements ConnectResolvers {
@Query('connect')
@UsePermissions({
action: AuthActionVerb.READ,
resource: 'connect/dynamic-remote-access',
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public connect() {
@@ -46,7 +46,7 @@ export class ConnectResolver implements ConnectResolvers {
@Mutation()
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: 'connect/dynamic-remote-access',
resource: Resource.CONNECT__REMOTE_ACCESS,
possession: AuthPossession.ANY,
})
public async enableDynamicRemoteAccess(