feat: api sign in / out (#642)

* feat: initial commit

* fix: minor issues with sign in endpoint

* feat: Permission check bypassing error

* test: fix snapshot
This commit is contained in:
Eli Bosley
2023-08-30 13:51:19 -04:00
committed by GitHub
parent a611fcf630
commit 32dea9e39a
19 changed files with 728 additions and 65 deletions

View File

@@ -1,5 +1,5 @@
[api]
version="3.1.1+bf51f1f0"
version="3.1.1+251f9020"
[local]
[notifier]
apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5"

View File

@@ -1,5 +1,5 @@
[api]
version="3.1.1+bf51f1f0"
version="3.1.1+251f9020"
[local]
[notifier]
apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5"

View File

@@ -48,7 +48,8 @@
"stop:dev": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development tsup --config ./tsup.config.ts --watch --onSuccess 'DOTENV_CONFIG_PATH=./.env.development node -r dotenv/config dist/unraid-api.cjs stop --debug'",
"start:report": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development LOG_CONTEXT=true tsup --config ./tsup.config.ts --watch --onSuccess 'DOTENV_CONFIG_PATH=./.env.development node -r dotenv/config dist/unraid-api.cjs report --debug'",
"start:docker": "docker compose run --rm builder-interactive",
"docker:dev": "docker-compose run --rm --service-ports dev"
"docker:dev": "docker-compose run --rm --service-ports dev",
"docker:test": "docker-compose run --rm builder npm run test"
},
"files": [
".env.staging",

View File

@@ -329,6 +329,16 @@ exports[`Returns default permissions 1`] = `
"attributes": "*",
"resource": "vars",
},
{
"action": "read:own",
"attributes": "*",
"resource": "connect",
},
{
"action": "update:own",
"attributes": "*",
"resource": "connect",
},
],
},
"user": {

View File

@@ -79,6 +79,8 @@ export const upc: Role = {
{ resource: 'registration', action: 'read:any', attributes: '*' },
{ resource: 'servers', action: 'read:any', attributes: '*' },
{ resource: 'vars', action: 'read:any', attributes: '*' },
{ resource: 'connect', action: 'read:own', attributes: '*' },
{ resource: 'connect', action: 'update:own', attributes: '*' }
],
};

View File

@@ -1,30 +1,49 @@
import { PermissionError } from '@app/core/errors/permission-error';
import { type User } from '@app/core/types/states/user';
import { checkPermission, type AccessControlOptions } from '@app/core/utils/permissions/check-permission';
import {
checkPermission,
type AccessControlOptions,
} from '@app/core/utils/permissions/check-permission';
import { logger } from '@app/core/log';
import { BYPASS_PERMISSION_CHECKS, NODE_ENV } from '@app/environment';
/**
* Ensure the user has the correct permissions.
* @param user The user to check permissions on.
* @param options A permissions object.
*/
export const ensurePermission = (user: User | undefined, options: AccessControlOptions) => {
const { resource, action, possession = 'own' } = options;
export const ensurePermission = (
user: User | undefined,
options: AccessControlOptions
) => {
const { resource, action, possession = 'own' } = options;
// Bail if no user was passed
if (!user) throw new PermissionError(`No user provided for authentication check when trying to access "${resource}".`);
// Bail if no user was passed
if (!user)
throw new PermissionError(
`No user provided for authentication check when trying to access "${resource}".`
);
const permissionGranted = checkPermission(user, {
resource,
action,
possession,
});
const permissionGranted = checkPermission(user, {
resource,
action,
possession,
});
if (process.env.NODE_ENV === 'development' && process.env.BYPASS_PERMISSION_CHECKS && !permissionGranted) {
logger.warn(`BYPASSING_PERMISSION_CHECK: ${user.name} doesn't have permission to access "${resource}".`);
return;
}
if (
NODE_ENV === 'development' &&
BYPASS_PERMISSION_CHECKS === true &&
!permissionGranted
) {
logger.warn(
`BYPASSING_PERMISSION_CHECK: ${user.name} doesn't have permission to access "${resource}".`
);
return;
}
// Bail if user doesn't have permission
if (!permissionGranted) throw new PermissionError(`${user.name} doesn't have permission to access "${resource}".`);
// Bail if user doesn't have permission
if (!permissionGranted)
throw new PermissionError(
`${user.name} doesn't have permission to access "${resource}".`
);
};

View File

@@ -12,4 +12,5 @@ export const GRAPHQL_INTROSPECTION = Boolean(
INTROSPECTION ?? DEBUG ?? ENVIRONMENT !== 'production'
);
export const PORT = process.env.PORT ?? '/var/run/unraid-api.sock';
export const DRY_RUN = process.env.DRY_RUN === 'true';
export const DRY_RUN = process.env.DRY_RUN === 'true';
export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true';

View File

@@ -2,7 +2,7 @@
import * as Types from '@app/graphql/generated/api/types';
import { z } from 'zod'
import { ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Device, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, DockerContainer, DockerNetwork, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Notification, NotificationFilter, NotificationInput, NotificationType, Os, Owner, ParityCheck, Partition, Pci, Permissions, ProfileModel, Registration, RegistrationState, RelayResponse, Scope, Server, ServerStatus, Service, Share, System, Temperature, Theme, TwoFactorLocal, TwoFactorRemote, TwoFactorWithToken, TwoFactorWithoutToken, UnassignedDevice, Uptime, Usb, User, Vars, Versions, VmDomain, VmNetwork, VmState, Vms, Welcome, addApiKeyInput, addScopeInput, addScopeToApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, testMutationInput, testQueryInput, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
import { AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Device, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, DockerContainer, DockerNetwork, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Notification, NotificationFilter, NotificationInput, NotificationType, Os, Owner, ParityCheck, Partition, Pci, Permissions, ProfileModel, Registration, RegistrationState, RelayResponse, Scope, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, TwoFactorLocal, TwoFactorRemote, TwoFactorWithToken, TwoFactorWithoutToken, UnassignedDevice, Uptime, Usb, User, Vars, Versions, VmDomain, VmNetwork, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addScopeInput, addScopeToApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
type Properties<T> = Required<{
@@ -15,6 +15,12 @@ export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== und
export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v));
export function AllowedOriginInputSchema(): z.ZodObject<Properties<AllowedOriginInput>> {
return z.object<Properties<AllowedOriginInput>>({
origins: z.array(z.string())
})
}
export function ApiKeySchema(): z.ZodObject<Properties<ApiKey>> {
return z.object<Properties<ApiKey>>({
__typename: z.literal('ApiKey').optional(),
@@ -155,6 +161,24 @@ export function ConfigSchema(): z.ZodObject<Properties<Config>> {
export const ConfigErrorStateSchema = z.nativeEnum(ConfigErrorState);
export function ConnectSignInInputSchema(): z.ZodObject<Properties<ConnectSignInInput>> {
return z.object<Properties<ConnectSignInInput>>({
accessToken: z.string().nullish(),
apiKey: z.string(),
idToken: z.string().nullish(),
refreshToken: z.string().nullish(),
userInfo: z.lazy(() => ConnectUserInfoInputSchema().nullish())
})
}
export function ConnectUserInfoInputSchema(): z.ZodObject<Properties<ConnectUserInfoInput>> {
return z.object<Properties<ConnectUserInfoInput>>({
avatar: z.string().nullish(),
email: z.string(),
preferred_username: z.string()
})
}
export function ContainerHostConfigSchema(): z.ZodObject<Properties<ContainerHostConfig>> {
return z.object<Properties<ContainerHostConfig>>({
__typename: z.literal('ContainerHostConfig').optional(),
@@ -715,6 +739,14 @@ export function ServiceSchema(): z.ZodObject<Properties<Service>> {
})
}
export function SetupRemoteAccessInputSchema(): z.ZodObject<Properties<SetupRemoteAccessInput>> {
return z.object<Properties<SetupRemoteAccessInput>>({
accessType: WAN_ACCESS_TYPESchema,
forwardType: WAN_FORWARD_TYPESchema.nullish(),
port: z.number().nullish()
})
}
export function ShareSchema(): z.ZodObject<Properties<Share>> {
return z.object<Properties<Share>>({
__typename: z.literal('Share').optional(),
@@ -1072,6 +1104,10 @@ export function VmsSchema(): z.ZodObject<Properties<Vms>> {
})
}
export const WAN_ACCESS_TYPESchema = z.nativeEnum(WAN_ACCESS_TYPE);
export const WAN_FORWARD_TYPESchema = z.nativeEnum(WAN_FORWARD_TYPE);
export function WelcomeSchema(): z.ZodObject<Properties<Welcome>> {
return z.object<Properties<Welcome>>({
__typename: z.literal('Welcome').optional(),
@@ -1132,19 +1168,6 @@ export const mdStateSchema = z.nativeEnum(mdState);
export const registrationTypeSchema = z.nativeEnum(registrationType);
export function testMutationInputSchema(): z.ZodObject<Properties<testMutationInput>> {
return z.object<Properties<testMutationInput>>({
state: z.string()
})
}
export function testQueryInputSchema(): z.ZodObject<Properties<testQueryInput>> {
return z.object<Properties<testQueryInput>>({
optional: z.boolean().nullish(),
state: z.string()
})
}
export function updateApikeyInputSchema(): z.ZodObject<Properties<updateApikeyInput>> {
return z.object<Properties<updateApikeyInput>>({
description: z.string().nullish(),

View File

@@ -17,9 +17,14 @@ export type Scalars = {
DateTime: string;
JSON: { [key: string]: any };
Long: number;
Port: number;
UUID: string;
};
export type AllowedOriginInput = {
origins: Array<Scalars['String']>;
};
export type ApiKey = {
__typename?: 'ApiKey';
description?: Maybe<Scalars['String']>;
@@ -238,6 +243,20 @@ export enum ConfigErrorState {
WITHDRAWN = 'WITHDRAWN'
}
export type ConnectSignInInput = {
accessToken?: InputMaybe<Scalars['String']>;
apiKey: Scalars['String'];
idToken?: InputMaybe<Scalars['String']>;
refreshToken?: InputMaybe<Scalars['String']>;
userInfo?: InputMaybe<ConnectUserInfoInput>;
};
export type ConnectUserInfoInput = {
avatar?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
preferred_username: Scalars['String'];
};
export type ContainerHostConfig = {
__typename?: 'ContainerHostConfig';
networkMode: Scalars['String'];
@@ -564,6 +583,8 @@ export type Mutation = {
/** Cancel parity check */
cancelParityCheck?: Maybe<Scalars['JSON']>;
clearArrayDiskStatistics?: Maybe<Scalars['JSON']>;
connectSignIn: Scalars['Boolean'];
connectSignOut: Scalars['Boolean'];
/** Delete a user */
deleteUser?: Maybe<User>;
/** Get an existing API key */
@@ -578,6 +599,8 @@ export type Mutation = {
/** Resume parity check */
resumeParityCheck?: Maybe<Scalars['JSON']>;
sendNotification?: Maybe<Notification>;
setAdditionalAllowedOrigins: Array<Scalars['String']>;
setupRemoteAccess: Scalars['Boolean'];
shutdown?: Maybe<Scalars['String']>;
/** Start array */
startArray?: Maybe<ArrayType>;
@@ -585,7 +608,6 @@ export type Mutation = {
startParityCheck?: Maybe<Scalars['JSON']>;
/** Stop array */
stopArray?: Maybe<ArrayType>;
testMutation?: Maybe<Scalars['JSON']>;
unmountArrayDisk?: Maybe<Disk>;
/** Update an existing API key */
updateApikey?: Maybe<ApiKey>;
@@ -623,6 +645,11 @@ export type MutationclearArrayDiskStatisticsArgs = {
};
export type MutationconnectSignInArgs = {
input: ConnectSignInInput;
};
export type MutationdeleteUserArgs = {
input: deleteUserInput;
};
@@ -655,14 +682,18 @@ export type MutationsendNotificationArgs = {
};
export type MutationstartParityCheckArgs = {
correct?: InputMaybe<Scalars['Boolean']>;
export type MutationsetAdditionalAllowedOriginsArgs = {
input: AllowedOriginInput;
};
export type MutationtestMutationArgs = {
id: Scalars['String'];
input?: InputMaybe<testMutationInput>;
export type MutationsetupRemoteAccessArgs = {
input: SetupRemoteAccessInput;
};
export type MutationstartParityCheckArgs = {
correct?: InputMaybe<Scalars['Boolean']>;
};
@@ -886,7 +917,6 @@ export type Query = {
servers: Array<Server>;
/** Network Shares */
shares?: Maybe<Array<Maybe<Share>>>;
testQuery?: Maybe<Scalars['JSON']>;
twoFactor?: Maybe<TwoFactorWithToken>;
unassignedDevices?: Maybe<Array<Maybe<UnassignedDevice>>>;
/** User account */
@@ -937,12 +967,6 @@ export type QueryserverArgs = {
};
export type QuerytestQueryArgs = {
id: Scalars['String'];
input?: InputMaybe<testQueryInput>;
};
export type QueryuserArgs = {
id: Scalars['ID'];
};
@@ -1051,6 +1075,12 @@ export type Service = {
version?: Maybe<Scalars['String']>;
};
export type SetupRemoteAccessInput = {
accessType: WAN_ACCESS_TYPE;
forwardType?: InputMaybe<WAN_FORWARD_TYPE>;
port?: InputMaybe<Scalars['Port']>;
};
/** Network Share */
export type Share = {
__typename?: 'Share';
@@ -1105,7 +1135,6 @@ export type Subscription = {
service?: Maybe<Array<Service>>;
share: Share;
shares?: Maybe<Array<Share>>;
testSubscription: Scalars['String'];
twoFactor?: Maybe<TwoFactorWithoutToken>;
unassignedDevices?: Maybe<Array<UnassignedDevice>>;
user: User;
@@ -1503,6 +1532,17 @@ export type Vms = {
domain?: Maybe<Array<VmDomain>>;
};
export enum WAN_ACCESS_TYPE {
ALWAYS = 'ALWAYS',
DISABLED = 'DISABLED',
DYNAMIC = 'DYNAMIC'
}
export enum WAN_FORWARD_TYPE {
STATIC = 'STATIC',
UPNP = 'UPNP'
}
export type Welcome = {
__typename?: 'Welcome';
message: Scalars['String'];
@@ -1566,15 +1606,6 @@ export enum registrationType {
TRIAL = 'TRIAL'
}
export type testMutationInput = {
state: Scalars['String'];
};
export type testQueryInput = {
optional?: InputMaybe<Scalars['Boolean']>;
state: Scalars['String'];
};
export type updateApikeyInput = {
description?: InputMaybe<Scalars['String']>;
expiresAt: Scalars['Long'];
@@ -1656,6 +1687,7 @@ export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs
/** Mapping between all available schema types and the resolvers types */
export type ResolversTypes = ResolversObject<{
AllowedOriginInput: AllowedOriginInput;
ApiKey: ResolverTypeWrapper<ApiKey>;
ApiKeyResponse: ResolverTypeWrapper<ApiKeyResponse>;
Array: ResolverTypeWrapper<ArrayType>;
@@ -1674,6 +1706,8 @@ export type ResolversTypes = ResolversObject<{
CloudResponse: ResolverTypeWrapper<CloudResponse>;
Config: ResolverTypeWrapper<Config>;
ConfigErrorState: ConfigErrorState;
ConnectSignInInput: ConnectSignInInput;
ConnectUserInfoInput: ConnectUserInfoInput;
ContainerHostConfig: ResolverTypeWrapper<ContainerHostConfig>;
ContainerMount: ResolverTypeWrapper<ContainerMount>;
ContainerPort: ResolverTypeWrapper<ContainerPort>;
@@ -1722,6 +1756,7 @@ export type ResolversTypes = ResolversObject<{
Partition: ResolverTypeWrapper<Partition>;
Pci: ResolverTypeWrapper<Pci>;
Permissions: ResolverTypeWrapper<Permissions>;
Port: ResolverTypeWrapper<Scalars['Port']>;
ProfileModel: ResolverTypeWrapper<ProfileModel>;
Query: ResolverTypeWrapper<{}>;
Registration: ResolverTypeWrapper<Registration>;
@@ -1731,6 +1766,7 @@ export type ResolversTypes = ResolversObject<{
Server: ResolverTypeWrapper<Server>;
ServerStatus: ServerStatus;
Service: ResolverTypeWrapper<Service>;
SetupRemoteAccessInput: SetupRemoteAccessInput;
Share: ResolverTypeWrapper<Share>;
String: ResolverTypeWrapper<Scalars['String']>;
Subscription: ResolverTypeWrapper<{}>;
@@ -1753,6 +1789,8 @@ export type ResolversTypes = ResolversObject<{
VmNetwork: ResolverTypeWrapper<VmNetwork>;
VmState: VmState;
Vms: ResolverTypeWrapper<Vms>;
WAN_ACCESS_TYPE: WAN_ACCESS_TYPE;
WAN_FORWARD_TYPE: WAN_FORWARD_TYPE;
Welcome: ResolverTypeWrapper<Welcome>;
addApiKeyInput: addApiKeyInput;
addScopeInput: addScopeInput;
@@ -1763,14 +1801,13 @@ export type ResolversTypes = ResolversObject<{
deleteUserInput: deleteUserInput;
mdState: mdState;
registrationType: registrationType;
testMutationInput: testMutationInput;
testQueryInput: testQueryInput;
updateApikeyInput: updateApikeyInput;
usersInput: usersInput;
}>;
/** Mapping between all available schema types and the resolvers parents */
export type ResolversParentTypes = ResolversObject<{
AllowedOriginInput: AllowedOriginInput;
ApiKey: ApiKey;
ApiKeyResponse: ApiKeyResponse;
Array: ArrayType;
@@ -1783,6 +1820,8 @@ export type ResolversParentTypes = ResolversObject<{
Cloud: Cloud;
CloudResponse: CloudResponse;
Config: Config;
ConnectSignInInput: ConnectSignInInput;
ConnectUserInfoInput: ConnectUserInfoInput;
ContainerHostConfig: ContainerHostConfig;
ContainerMount: ContainerMount;
ContainerPort: ContainerPort;
@@ -1821,6 +1860,7 @@ export type ResolversParentTypes = ResolversObject<{
Partition: Partition;
Pci: Pci;
Permissions: Permissions;
Port: Scalars['Port'];
ProfileModel: ProfileModel;
Query: {};
Registration: Registration;
@@ -1828,6 +1868,7 @@ export type ResolversParentTypes = ResolversObject<{
Scope: Scope;
Server: Server;
Service: Service;
SetupRemoteAccessInput: SetupRemoteAccessInput;
Share: Share;
String: Scalars['String'];
Subscription: {};
@@ -1855,8 +1896,6 @@ export type ResolversParentTypes = ResolversObject<{
arrayDiskInput: arrayDiskInput;
authenticateInput: authenticateInput;
deleteUserInput: deleteUserInput;
testMutationInput: testMutationInput;
testQueryInput: testQueryInput;
updateApikeyInput: updateApikeyInput;
usersInput: usersInput;
}>;
@@ -2245,6 +2284,8 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
addUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationaddUserArgs, 'input'>>;
cancelParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
clearArrayDiskStatistics?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<MutationclearArrayDiskStatisticsArgs, 'id'>>;
connectSignIn?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationconnectSignInArgs, 'input'>>;
connectSignOut?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
deleteUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationdeleteUserArgs, 'input'>>;
getApiKey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationgetApiKeyArgs, 'name'>>;
login?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationloginArgs, 'password' | 'username'>>;
@@ -2254,11 +2295,12 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
removeDiskFromArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationremoveDiskFromArrayArgs>>;
resumeParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
sendNotification?: Resolver<Maybe<ResolversTypes['Notification']>, ParentType, ContextType, RequireFields<MutationsendNotificationArgs, 'notification'>>;
setAdditionalAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationsetAdditionalAllowedOriginsArgs, 'input'>>;
setupRemoteAccess?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationsetupRemoteAccessArgs, 'input'>>;
shutdown?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
startArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
startParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, Partial<MutationstartParityCheckArgs>>;
stopArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
testMutation?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<MutationtestMutationArgs, 'id'>>;
unmountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationunmountArrayDiskArgs, 'id'>>;
updateApikey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationupdateApikeyArgs, 'name'>>;
}>;
@@ -2404,6 +2446,10 @@ export type PermissionsResolvers<ContextType = Context, ParentType extends Resol
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export interface PortScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['Port'], any> {
name: 'Port';
}
export type ProfileModelResolvers<ContextType = Context, ParentType extends ResolversParentTypes['ProfileModel'] = ResolversParentTypes['ProfileModel']> = ResolversObject<{
avatar?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
url?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -2438,7 +2484,6 @@ export type QueryResolvers<ContextType = Context, ParentType extends ResolversPa
server?: Resolver<Maybe<ResolversTypes['Server']>, ParentType, ContextType, RequireFields<QueryserverArgs, 'name'>>;
servers?: Resolver<Array<ResolversTypes['Server']>, ParentType, ContextType>;
shares?: Resolver<Maybe<Array<Maybe<ResolversTypes['Share']>>>, ParentType, ContextType>;
testQuery?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<QuerytestQueryArgs, 'id'>>;
twoFactor?: Resolver<Maybe<ResolversTypes['TwoFactorWithToken']>, ParentType, ContextType>;
unassignedDevices?: Resolver<Maybe<Array<Maybe<ResolversTypes['UnassignedDevice']>>>, ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<QueryuserArgs, 'id'>>;
@@ -2537,7 +2582,6 @@ export type SubscriptionResolvers<ContextType = Context, ParentType extends Reso
service?: SubscriptionResolver<Maybe<Array<ResolversTypes['Service']>>, "service", ParentType, ContextType, RequireFields<SubscriptionserviceArgs, 'name'>>;
share?: SubscriptionResolver<ResolversTypes['Share'], "share", ParentType, ContextType, RequireFields<SubscriptionshareArgs, 'id'>>;
shares?: SubscriptionResolver<Maybe<Array<ResolversTypes['Share']>>, "shares", ParentType, ContextType>;
testSubscription?: SubscriptionResolver<ResolversTypes['String'], "testSubscription", ParentType, ContextType>;
twoFactor?: SubscriptionResolver<Maybe<ResolversTypes['TwoFactorWithoutToken']>, "twoFactor", ParentType, ContextType>;
unassignedDevices?: SubscriptionResolver<Maybe<Array<ResolversTypes['UnassignedDevice']>>, "unassignedDevices", ParentType, ContextType>;
user?: SubscriptionResolver<ResolversTypes['User'], "user", ParentType, ContextType, RequireFields<SubscriptionuserArgs, 'id'>>;
@@ -2911,6 +2955,7 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
Partition?: PartitionResolvers<ContextType>;
Pci?: PciResolvers<ContextType>;
Permissions?: PermissionsResolvers<ContextType>;
Port?: GraphQLScalarType;
ProfileModel?: ProfileModelResolvers<ContextType>;
Query?: QueryResolvers<ContextType>;
Registration?: RegistrationResolvers<ContextType>;

View File

@@ -0,0 +1,68 @@
import { ensurePermission } from '@app/core/utils/index';
import { type MutationResolvers } from '@app/graphql/generated/api/types';
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver';
import { getters, store } from '@app/store/index';
import { setApiKeyState } from '@app/store/modules/apikey';
import { loginUser, signIn } from '@app/store/modules/config';
import { FileLoadStatus } from '@app/store/types';
import { GraphQLError } from 'graphql';
import { decodeJwt } from 'jose';
export const connectSignIn: MutationResolvers['connectSignIn'] = async (
_,
args,
context
) => {
ensurePermission(context.user, {
resource: 'connect',
possession: 'own',
action: 'update',
});
if (getters.emhttp().status === FileLoadStatus.LOADED) {
const result = await validateApiKeyWithKeyServer({
apiKey: args.input.apiKey,
flashGuid: getters.emhttp().var.flashGuid,
});
if (result !== API_KEY_STATUS.API_KEY_VALID) {
throw new GraphQLError(
`Validating API Key Failed with Error: ${result}`
);
}
const userInfo = args.input.idToken
? decodeJwt(args.input.idToken)
: args.input.userInfo ?? null;
if (
!userInfo ||
!userInfo.preferred_username ||
!userInfo.email ||
typeof userInfo.preferred_username !== 'string' ||
typeof userInfo.email !== 'string'
) {
throw new GraphQLError('Missing User Attributes');
}
store.dispatch(setApiKeyState(API_KEY_STATUS.API_KEY_VALID));
store.dispatch(
signIn({
apikey: args.input.apiKey,
username: userInfo.preferred_username,
email: userInfo.email,
avatar:
typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
})
);
// @TODO once we deprecate old sign in method, switch this to do all validation requests
await store.dispatch(
loginUser({
avatar: '',
username: userInfo.preferred_username,
email: userInfo.email,
})
);
return true;
} else {
return false;
}
};

View File

@@ -0,0 +1,20 @@
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { type MutationResolvers } from '@app/graphql/generated/api/types';
import { store } from '@app/store/index';
import { logoutUser, signOut } from '@app/store/modules/config';
export const connectSignOut: MutationResolvers['connectSignOut'] = async (
_,
__,
context
) => {
ensurePermission(context.user, {
resource: 'connect',
possession: 'own',
action: 'update',
});
store.dispatch(signOut());
await store.dispatch(logoutUser({ reason: 'Manual Sign Out With API' }));
return true;
};

View File

@@ -1,6 +1,10 @@
import { type Resolvers } from '@app/graphql/generated/api/types';
import { sendNotification } from './notifications';
import { connectSignIn } from '@app/graphql/resolvers/mutation/connect/connect-sign-in';
import { connectSignOut } from '@app/graphql/resolvers/mutation/connect/connect-sign-out';
export const Mutation: Resolvers['Mutation'] = {
sendNotification,
connectSignIn,
connectSignOut
};

View File

@@ -0,0 +1,28 @@
import { DateTimeResolver, JSONResolver, PortResolver, UUIDResolver } from 'graphql-scalars';
import { Query } from '@app/graphql/resolvers/query';
import { Mutation } from '@app/graphql/resolvers/mutation';
import { Subscription } from '@app/graphql/resolvers/subscription';
import { UserAccount } from '@app/graphql/resolvers/user-account';
import { type Resolvers } from '../generated/api/types';
import { infoSubResolvers } from './query/info';
import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long';
import { domainResolver } from '@app/core/modules/index';
export const resolvers: Resolvers = {
JSON: JSONResolver,
Long: GraphQLLong,
UUID: UUIDResolver,
DateTime: DateTimeResolver,
Port: PortResolver,
Query,
Mutation,
Subscription,
UserAccount,
Info: {
...infoSubResolvers,
},
Vms: {
domain: domainResolver,
},
};

View File

@@ -0,0 +1,41 @@
input ConnectUserInfoInput {
preferred_username: String!
email: String!
avatar: String
}
input ConnectSignInInput {
apiKey: String!
idToken: String
userInfo: ConnectUserInfoInput
accessToken: String
refreshToken: String
}
input AllowedOriginInput {
origins: [String!]!
}
enum WAN_ACCESS_TYPE {
DYNAMIC
ALWAYS
DISABLED
}
enum WAN_FORWARD_TYPE {
UPNP
STATIC
}
input SetupRemoteAccessInput {
accessType: WAN_ACCESS_TYPE!
forwardType: WAN_FORWARD_TYPE
port: Port
}
type Mutation {
connectSignIn(input: ConnectSignInInput!): Boolean!
connectSignOut: Boolean!
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
}

46
api/src/graphql/types.ts Normal file
View File

@@ -0,0 +1,46 @@
import { mergeTypeDefs } from '@graphql-tools/merge';
import { gql } from 'graphql-tag';
import { typeDefs } from '@app/graphql/schema/index';
export const baseTypes = [
gql`
scalar JSON
scalar Long
scalar UUID
scalar DateTime
scalar Port
directive @subscription(channel: String!) on FIELD_DEFINITION
type Welcome {
message: String!
}
type Query {
# This should always be available even for guest users
welcome: Welcome @func(module: "getWelcome")
online: Boolean
info: Info
}
type Mutation {
login(username: String!, password: String!): String
sendNotification(notification: NotificationInput!): Notification
shutdown: String
reboot: String
}
type Subscription {
ping: String!
info: Info!
online: Boolean!
}
`,
];
export const types = mergeTypeDefs([
...baseTypes,
typeDefs,
]);
export default types;

View File

@@ -38,7 +38,7 @@ export const handleRemoteAccessEvent = createAsyncThunk<void, RemoteAccessEventF
break;
case RemoteAccessEventActionType.PING:
// Ping - would continue remote access if necessary;
RemoteAccessController.instance.extendRemoteAccess({ getState, dispatch });
RemoteAccessController.instance.extendRemoteAccess({ dispatch });
break;
case RemoteAccessEventActionType.END:
// End

View File

@@ -0,0 +1,355 @@
import { parseConfig } from '@app/core/utils/misc/parse-config';
import {
type MyServersConfig,
type MyServersConfigMemory,
} from '@app/types/my-servers-config';
import {
createAsyncThunk,
createSlice,
type PayloadAction,
} from '@reduxjs/toolkit';
import { access } from 'fs/promises';
import merge from 'lodash/merge';
import { FileLoadStatus } from '@app/store/types';
import { F_OK } from 'constants';
import { type RecursivePartial } from '@app/types';
import { MinigraphStatus, type Owner } from '@app/graphql/generated/api/types';
import { type RootState } from '@app/store';
import { randomBytes } from 'crypto';
import { logger } from '@app/core/log';
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer';
import { writeFileSync } from 'fs';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
import { pubsub } from '@app/core/pubsub';
import { DynamicRemoteAccessType } from '@app/remoteAccess/types';
import { isEqual } from 'lodash';
export type SliceState = {
status: FileLoadStatus;
nodeEnv: string;
} & MyServersConfigMemory;
export const initialState: SliceState = {
status: FileLoadStatus.UNLOADED,
nodeEnv: process.env.NODE_ENV ?? 'production',
remote: {
'2Fa': '',
wanaccess: '',
wanport: '',
upnpEnabled: '',
apikey: '',
email: '',
username: '',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
allowedOrigins: '',
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
},
local: {
showT2Fa: '',
'2Fa': '',
},
api: {
extraOrigins: '',
version: '',
},
upc: {
apikey: '',
},
notifier: {
apikey: '',
},
connectionStatus: {
minigraph: MinigraphStatus.PRE_INIT,
upnpStatus: '',
},
} as const;
export const loginUser = createAsyncThunk<
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username'>,
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username'>,
{ state: RootState }
>('config/login-user', async (userInfo) => {
logger.info('Logging in user: %s', userInfo.username);
const owner: Owner = {
username: userInfo.username,
avatar: userInfo.avatar,
};
await pubsub.publish('owner', { owner });
return userInfo;
});
export const logoutUser = createAsyncThunk<
void,
{ reason?: string },
{ state: RootState }
>('config/logout-user', async ({ reason }) => {
logger.info('Logging out user: %s', reason ?? 'No reason provided');
const { pubsub } = await import('@app/core/pubsub');
// Publish to servers endpoint
await pubsub.publish('servers', {
servers: [],
});
const owner: Owner = {
username: 'root',
url: '',
avatar: '',
};
// Publish to owner endpoint
await pubsub.publish('owner', { owner });
});
/**
* Load the myservers.cfg into the store. Returns null if the state after loading doesn't change
*
* Note: If the file doesn't exist this will fallback to default values.
*/
enum CONFIG_LOAD_ERROR {
CONFIG_EQUAL = 'CONFIG_EQUAL',
CONFIG_CORRUPTED = 'CONFIG_CORRUPTED',
}
type LoadFailureWithConfig = {
type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED;
error: Error | null;
config: MyServersConfig;
};
type LoadFailureConfigEqual = {
type: CONFIG_LOAD_ERROR.CONFIG_EQUAL;
};
type ConfigRejectedValues = LoadFailureConfigEqual | LoadFailureWithConfig;
const generateApiKeysIfNotExistent = (
file: RecursivePartial<MyServersConfig>
): MyServersConfig => {
const newConfigFile = merge(file, {
upc: {
apikey:
file.upc?.apikey?.trim()?.length === 64
? file.upc?.apikey
: `unupc_${randomBytes(58).toString('hex')}`.substring(
0,
64
),
},
notifier: {
apikey:
file.notifier?.apikey?.trim().length === 64
? file.notifier?.apikey
: `unnotify_${randomBytes(58).toString('hex')}`.substring(
0,
64
),
},
}) as MyServersConfig;
return newConfigFile;
};
export const loadConfigFile = createAsyncThunk<
MyServersConfig,
string | undefined,
{
state: RootState;
rejectValue: ConfigRejectedValues;
}
>(
'config/load-config-file',
async (filePath, { getState, rejectWithValue }) => {
try {
const { paths, config } = getState();
const path = filePath ?? paths['myservers-config'];
const fileExists = await access(path, F_OK)
.then(() => true)
.catch(() => false);
if (!fileExists) {
throw new Error('Config File Missing');
}
const file = fileExists
? parseConfig<RecursivePartial<MyServersConfig>>({
filePath: path,
type: 'ini',
})
: {};
const newConfigFile = generateApiKeysIfNotExistent(file);
const isNewlyLoadedConfigEqual = isEqual(
getWriteableConfig(newConfigFile as SliceState, 'flash'),
getWriteableConfig(config, 'flash')
);
if (isNewlyLoadedConfigEqual) {
logger.warn(
'Not loading config because it is the same as before'
);
return rejectWithValue({
type: CONFIG_LOAD_ERROR.CONFIG_EQUAL,
});
}
return newConfigFile;
} catch (error: unknown) {
logger.warn('Config file is corrupted, recreating config', error);
const config = getWriteableConfig(initialState, 'flash');
const newConfig = generateApiKeysIfNotExistent(config);
newConfig.remote.wanaccess = 'no';
const serializedConfig = safelySerializeObjectToIni(newConfig);
writeFileSync(
getState().paths['myservers-config'],
serializedConfig
);
return rejectWithValue({
type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED,
error:
error instanceof Error ? error : new Error('Unknown Error'),
config: newConfig,
});
}
}
);
export const config = createSlice({
name: 'config',
initialState,
reducers: {
updateUserConfig(
state,
action: PayloadAction<RecursivePartial<MyServersConfig>>
) {
return merge(state, action.payload);
},
updateAccessTokens(
state,
action: PayloadAction<
Partial<
Pick<
Pick<MyServersConfig, 'remote'>['remote'],
'accesstoken' | 'refreshtoken' | 'idtoken'
>
>
>
) {
return merge(state, { remote: action.payload });
},
updateAllowedOrigins(state, action: PayloadAction<string[]>) {
state.remote.allowedOrigins = action.payload.join(', ');
},
setUpnpState(
state,
action: PayloadAction<{
enabled?: 'no' | 'yes' | 'auto';
status?: string | null;
}>
) {
if (action.payload.enabled) {
state.remote.upnpEnabled = action.payload.enabled;
}
if (action.payload.status) {
state.connectionStatus.upnpStatus = action.payload.status;
}
},
setWanPortToValue(state, action: PayloadAction<number>) {
logger.debug('Wan port set to %s', action.payload);
state.remote.wanport = String(action.payload);
},
setWanAccess(state, action: PayloadAction<'yes' | 'no'>) {
state.remote.wanaccess = action.payload;
},
signIn: (
state,
action: PayloadAction<
Pick<
MyServersConfig['remote'],
'apikey'
> & Partial<Pick<MyServersConfig['remote'],
'idtoken' | 'accesstoken' | 'refreshtoken' | 'username' | 'avatar' | 'email'>>
>
) => {
state.remote.apikey = action.payload.apikey;
state.remote.idtoken = action.payload.idtoken ?? '';
state.remote.accesstoken = action.payload.accesstoken ?? ''
state.remote.refreshtoken = action.payload.refreshtoken ?? ''
state.remote.email = action.payload.email ?? '',
state.remote.username = action.payload.username ?? '',
state.remote.avatar = action.payload.avatar ?? ''
},
signOut: (state) => {
state.remote.apikey = '';
state.remote.idtoken = '';
state.remote.accesstoken = '';
state.remote.refreshtoken = '';
state.remote.email = '';
state.remote.username = '';
state.remote.avatar = '';
}
},
extraReducers(builder) {
builder.addCase(loadConfigFile.pending, (state) => {
state.status = FileLoadStatus.LOADING;
});
builder.addCase(loadConfigFile.fulfilled, (state, action) => {
if (action.payload) {
merge(state, action.payload, { status: FileLoadStatus.LOADED });
} else {
state.status = FileLoadStatus.LOADED;
}
});
builder.addCase(loadConfigFile.rejected, (state, action) => {
switch (action.payload?.type) {
case CONFIG_LOAD_ERROR.CONFIG_EQUAL:
logger.debug('Configs equivalent');
state.status = FileLoadStatus.LOADED;
break;
case CONFIG_LOAD_ERROR.CONFIG_CORRUPTED:
logger.debug(
'Config File Load Failed - %o',
action.payload.error
);
merge(state, action.payload.config);
state.status = FileLoadStatus.LOADED;
break;
default:
logger.error('Config File Load Failed', action.error);
}
});
builder.addCase(logoutUser.pending, (state) => {
merge(state, {
remote: {
apikey: '',
avatar: '',
email: '',
username: '',
},
});
});
builder.addCase(setGraphqlConnectionStatus, (state, action) => {
state.connectionStatus.minigraph = action.payload.status;
});
},
});
const { actions, reducer } = config;
export const {
updateUserConfig,
updateAccessTokens,
updateAllowedOrigins,
setUpnpState,
setWanPortToValue,
setWanAccess,
signIn,
signOut
} = actions;
export const configReducer = reducer;