Compare commits

...

2 Commits

Author SHA1 Message Date
Sylver a865996f31 feat: disabled users
resolves #41
2024-05-06 11:36:44 +08:00
Sylver db4728f1f7 fix: config byte parsing
resolves #42
2024-05-06 10:57:53 +08:00
14 changed files with 274 additions and 160 deletions

View File

@ -1,5 +1,4 @@
import { loadConfig } from '@ryanke/venera';
import bytes from 'bytes';
import c from 'chalk';
import { randomBytes } from 'crypto';
import dedent from 'dedent';
@ -9,6 +8,7 @@ import z, { any, array, boolean, number, record, strictObject, string, union } f
import { fromZodError } from 'zod-validation-error';
import { expandMime } from './helpers/expand-mime.js';
import { HostService } from './modules/host/host.service.js';
import { parseBytes } from './helpers/parse-bytes.js';
export type MicroHost = ReturnType<typeof enhanceHost>;
@ -16,7 +16,7 @@ const schema = strictObject({
databaseUrl: string().startsWith('postgresql://'),
secret: string().min(6),
inquiries: string().email(),
uploadLimit: string().transform(bytes.parse),
uploadLimit: string().transform(parseBytes),
maxPasteLength: number().default(500000),
allowTypes: z
.union([array(string()), string()])
@ -25,7 +25,7 @@ const schema = strictObject({
storagePath: string(),
restrictFilesToHost: boolean().default(true),
purge: strictObject({
overLimit: string().transform(bytes.parse),
overLimit: string().transform(parseBytes),
afterTime: string().transform(ms),
}).optional(),
email: strictObject({
@ -36,7 +36,7 @@ const schema = strictObject({
strictObject({
from: union([array(string()), string()]).transform((value) => new Set(expandMime(value))),
to: string(),
minSize: string().transform(bytes.parse).optional(),
minSize: string().transform(parseBytes).optional(),
}),
).optional(),
hosts: array(

View File

@ -0,0 +1,28 @@
const UNITS = new Map<string, number>([
['B', 1],
['KB', 1024],
['K', 1024],
['MB', 1024 * 1024],
['M', 1024 * 1024],
['GB', 1024 * 1024 * 1024],
['G', 1024 * 1024 * 1024],
]);
const PATTERN = new RegExp(`(?<value>[0-9]+(?:\\.[0-9]+)?)\\s*(?<unit>${[...UNITS.keys()].join('|')})`, 'i')
export const parseBytes = (input:string) => {
const match = input.match(PATTERN)
if (!match) {
throw new Error('Invalid byte format')
}
const value = +match.groups!.value
const unit = match.groups!.unit.toUpperCase()
const unitMultiplier = UNITS.get(unit)
if (!unitMultiplier) {
throw new Error('Invalid byte format')
}
return value * unitMultiplier
}

View File

@ -116,6 +116,15 @@
"primary": false,
"nullable": true,
"mappedType": "array"
},
"disabled_reason": {
"name": "disabled_reason",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
}
},
"name": "users",
@ -285,7 +294,7 @@
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"length": 0,
"mappedType": "datetime"
},
"created_at": {
@ -295,7 +304,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"length": 0,
"mappedType": "datetime"
},
"owner_id": {
@ -385,7 +394,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"length": 0,
"mappedType": "datetime"
},
"owner_id": {
@ -472,7 +481,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"length": 0,
"mappedType": "datetime"
},
"skip_verification": {
@ -492,7 +501,7 @@
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"length": 0,
"mappedType": "datetime"
}
},
@ -595,6 +604,15 @@
"nullable": false,
"mappedType": "string"
},
"is_utf8": {
"name": "is_utf8",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "boolean"
},
"metadata_height": {
"name": "metadata_height",
"type": "int",
@ -647,7 +665,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"length": 0,
"mappedType": "datetime"
},
"owner_id": {
@ -792,7 +810,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"length": 0,
"mappedType": "datetime"
}
},
@ -853,7 +871,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"length": 0,
"mappedType": "datetime"
}
},

View File

@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240506030901 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "users" add column "disabled_reason" varchar(255) null;');
}
async down(): Promise<void> {
this.addSql('alter table "users" drop column "disabled_reason";');
}
}

View File

@ -0,0 +1,12 @@
import { UnauthorizedException } from '@nestjs/common';
export class AccountDisabledError extends UnauthorizedException {
constructor(message: string) {
super({
// nestjs will filter out any additional keys we add to this object for graphql.
// unfortunately, the only way for the frontend to pick this up without rewriting
// nestjs error handling is to append the type to the message.
message: `ACCOUNT_DISABLED: ${message}`,
});
}
}

View File

@ -7,6 +7,7 @@ import crypto from 'crypto';
import { authenticator } from 'otplib';
import { User } from '../user/user.entity.js';
import type { OTPEnabledDto } from './dto/otp-enabled.dto.js';
import { AccountDisabledError } from './account-disabled.error.js';
export enum TokenType {
USER = 'USER',
@ -68,6 +69,10 @@ export class AuthService {
await this.validateOTPCode(otpCode, user);
}
if (user.disabledReason) {
throw new AccountDisabledError(user.disabledReason)
}
return user;
}

View File

@ -4,6 +4,7 @@ import { Reflector } from '@nestjs/core';
import { Permission } from '../../../constants.js';
import { getRequest } from '../../../helpers/get-request.js';
import { UserService } from '../../user/user.service.js';
import { AccountDisabledError } from '../account-disabled.error.js';
@Injectable()
export class PermissionGuard implements CanActivate {
@ -17,6 +18,10 @@ export class PermissionGuard implements CanActivate {
const userId = request.user.id;
const user = await this.userService.getUser(userId, false);
if (!user) return false;
if (user.disabledReason) {
throw new AccountDisabledError(user.disabledReason)
}
if (this.userService.checkPermissions(user.permissions, Permission.ADMINISTRATOR)) return true;
if (!this.userService.checkPermissions(user.permissions, requiredPermissions)) return false;
return true;

View File

@ -7,6 +7,7 @@ import { Strategy } from 'passport-jwt';
import { config } from '../../../config.js';
import { User } from '../../user/user.entity.js';
import { TokenType } from '../auth.service.js';
import { AccountDisabledError } from '../account-disabled.error.js';
export interface JWTPayloadUser {
id: string;
@ -33,6 +34,10 @@ export class JWTStrategy extends PassportStrategy(Strategy) {
if (!payload.secret) throw new UnauthorizedException('Outdated JWT - try refresh your session');
const user = await this.userRepo.findOne({ secret: payload.secret });
if (!user) throw new UnauthorizedException('Invalid token secret');
if (user.disabledReason) {
throw new AccountDisabledError(user.disabledReason)
}
return user;
}
}

View File

@ -80,5 +80,9 @@ export class User {
@Property({ nullable: true, hidden: true, type: ArrayType })
otpRecoveryCodes?: string[];
@Exclude()
@Property({ hidden: true, nullable: true })
disabledReason?: string;
[OptionalProps]: 'permissions' | 'tags' | 'verifiedEmail';
}

View File

@ -23,13 +23,13 @@ const documents = {
types.GetFilesDocument,
'\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n':
types.GetPastesDocument,
'\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n':
types.LoginDocument,
'\n query Config {\n config {\n allowTypes\n inquiriesEmail\n requireEmails\n uploadLimit\n currentHost {\n normalised\n redirect\n }\n rootHost {\n normalised\n url\n }\n hosts {\n normalised\n }\n }\n }\n':
types.ConfigDocument,
'\n fragment RegularUser on User {\n id\n username\n email\n verifiedEmail\n }\n':
types.RegularUserFragmentDoc,
'\n query GetUser {\n user {\n ...RegularUser\n }\n }\n': types.GetUserDocument,
'\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n':
types.LoginDocument,
'\n mutation Logout {\n logout\n }\n': types.LogoutDocument,
'\n query GenerateOTP {\n generateOTP {\n recoveryCodes\n qrauthUrl\n secret\n }\n }\n':
types.GenerateOtpDocument,
@ -100,6 +100,12 @@ export function graphql(
export function graphql(
source: '\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n',
): (typeof documents)['\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n',
): (typeof documents)['\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -118,12 +124,6 @@ export function graphql(
export function graphql(
source: '\n query GetUser {\n user {\n ...RegularUser\n }\n }\n',
): (typeof documents)['\n query GetUser {\n user {\n ...RegularUser\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n',
): (typeof documents)['\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@ -377,6 +377,17 @@ export type GetPastesQuery = {
};
};
export type LoginMutationVariables = Exact<{
username: Scalars['String']['input'];
password: Scalars['String']['input'];
otp?: InputMaybe<Scalars['String']['input']>;
}>;
export type LoginMutation = {
__typename?: 'Mutation';
login: { __typename?: 'User'; id: string; username: string; email?: string | null; verifiedEmail: boolean };
};
export type ConfigQueryVariables = Exact<{ [key: string]: never }>;
export type ConfigQuery = {
@ -408,17 +419,6 @@ export type GetUserQuery = {
user: { __typename?: 'User'; id: string; username: string; email?: string | null; verifiedEmail: boolean };
};
export type LoginMutationVariables = Exact<{
username: Scalars['String']['input'];
password: Scalars['String']['input'];
otp?: InputMaybe<Scalars['String']['input']>;
}>;
export type LoginMutation = {
__typename?: 'Mutation';
login: { __typename?: 'User'; id: string; username: string; email?: string | null; verifiedEmail: boolean };
};
export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
@ -915,6 +915,77 @@ export const GetPastesDocument = {
},
],
} as unknown as DocumentNode<GetPastesQuery, GetPastesQueryVariables>;
export const LoginDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'Login' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'login' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'username' },
value: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'password' },
value: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'otpCode' },
value: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'RegularUser' } }],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'RegularUser' },
typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'username' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'verifiedEmail' } },
],
},
},
],
} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>;
export const ConfigDocument = {
kind: 'Document',
definitions: [
@ -1010,77 +1081,6 @@ export const GetUserDocument = {
},
],
} as unknown as DocumentNode<GetUserQuery, GetUserQueryVariables>;
export const LoginDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'Login' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'login' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'username' },
value: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'password' },
value: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'otpCode' },
value: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'RegularUser' } }],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'RegularUser' },
typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'username' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'verifiedEmail' } },
],
},
},
],
} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>;
export const LogoutDocument = {
kind: 'Document',
definitions: [

View File

@ -1,15 +1,32 @@
import clsx from 'clsx';
import type { FC, ReactNode } from 'react';
import { FiInfo } from 'react-icons/fi';
import { useMemo, type FC, type ReactNode } from 'react';
import { FiInfo, FiXOctagon } from 'react-icons/fi';
export enum WarningType {
Info = 'bg-purple-400 border-purple-400',
Error = 'bg-red-500 border-red-500',
}
export const Warning: FC<{ children: ReactNode; type?: WarningType; className?: string }> = ({
children,
type = WarningType.Info,
className,
}) => {
const classes = clsx('bg-opacity-40 border px-2 py-1 rounded text-sm flex items-center gap-2', className, type);
const icon = useMemo(() => {
switch (type) {
case WarningType.Error: {
return <FiXOctagon className="text-red-400 h-5 w-5" />;
}
case WarningType.Info: {
return <FiInfo className="text-purple-400 h-5 w-5" />;
}
}
}, [type]);
export const Warning: FC<{ children: ReactNode; className?: string }> = ({ children, className }) => {
const classes = clsx(
'bg-purple-400 bg-opacity-40 border border-purple-400 px-2 py-1 rounded text-sm flex items-center gap-2',
className,
);
return (
<div className={classes} role="alert">
<FiInfo className="text-purple-400 h-5 w-5" />
{icon}
{children}
</div>
);

View File

@ -10,17 +10,32 @@ import { Submit } from '../components/input/submit';
import { navigate } from '../helpers/routing';
import { useAsync } from '../hooks/useAsync';
import { useUser } from '../hooks/useUser';
import { useMutation } from '@urql/preact';
import { graphql } from '../@generated';
import { getErrorMessage } from '../helpers/get-error-message.helper';
import { Warning, WarningType } from '../components/warning';
const schema = Yup.object().shape({
username: Yup.string().required().min(2),
password: Yup.string().required().min(5),
});
const LoginMutation = graphql(`
mutation Login($username: String!, $password: String!, $otp: String) {
login(username: $username, password: $password, otpCode: $otp) {
...RegularUser
}
}
`);
export const LoginForm: FC = () => {
const user = useUser();
const [loginInfo, setLoginInfo] = useState<LoginMutationVariables | null>(null);
const [invalidOTP, setInvalidOTP] = useState(false);
const [disabledReason, setDisabledReason] = useState<string | null>(null);
const [otpRequired, setOtpRequired] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, loginMutation] = useMutation(LoginMutation);
const redirect = useCallback(() => {
const url = new URL(window.location.href);
const to = url.searchParams.get('to') ?? '/dashboard';
@ -35,26 +50,51 @@ export const LoginForm: FC = () => {
}, [user, redirect]);
const [login, loggingIn] = useAsync(async (values: LoginMutationVariables) => {
try {
setLoginInfo(values);
setInvalidOTP(false);
await user.login(values);
setError(null);
redirect();
} catch (error: any) {
if (user.otpRequired && error.message.toLowerCase().includes('invalid otp')) {
setInvalidOTP(true);
return;
} else if (error.message.toLowerCase().includes('unauthorized')) {
setError('Invalid username or password');
return;
setLoginInfo(values);
setInvalidOTP(false);
// i truly do not understand why this doesn't just throw.
const result = await loginMutation(values);
if (result.error) {
if (result.error.message.toLowerCase().includes('otp')) {
setOtpRequired(true);
}
throw error;
if (otpRequired && result.error.message.toLowerCase().includes('invalid otp')) {
setInvalidOTP(true);
} else if (result.error.message.includes('ACCOUNT_DISABLED')) {
const index = result.error.message.indexOf(':');
const message = result.error.message.slice(index + 1);
setDisabledReason(message);
} else if (result.error.message.toLowerCase().includes('unauthorized')) {
setError('Invalid username or password');
} else if (result.error.message.startsWith('ACCOUNT_DISABLED:')) {
const message = result.error.message.replace('ACCOUNT_DISABLED: ', '');
setError(message);
} else {
const message = getErrorMessage(result.error);
setError(message || 'An unknown error occured.');
}
return;
}
setError(null);
redirect();
});
if (user.otpRequired && loginInfo) {
if (disabledReason) {
return (
<Warning type={WarningType.Error} className="w-full">
<div>
<h3>Your account is disabled</h3>
<p className="font-mono">{disabledReason}</p>
</div>
</Warning>
);
}
if (otpRequired && loginInfo) {
return (
<div className="w-full">
<OtpInput

View File

@ -1,8 +1,8 @@
import type { CombinedError, TypedDocumentNode } from '@urql/preact';
import { useMutation, useQuery } from '@urql/preact';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { graphql } from '../@generated/gql';
import type { GetUserQuery, LoginMutationVariables } from '../@generated/graphql';
import type { GetUserQuery } from '../@generated/graphql';
import { type RegularUserFragment } from '../@generated/graphql';
import { navigate } from '../helpers/routing';
import { useAsync } from './useAsync';
@ -24,42 +24,12 @@ const UserQuery = graphql(`
}
`);
const LoginMutation = graphql(`
mutation Login($username: String!, $password: String!, $otp: String) {
login(username: $username, password: $password, otpCode: $otp) {
...RegularUser
}
}
`);
const LogoutMutation = graphql(`
mutation Logout {
logout
}
`);
export const useLoginUser = () => {
const [otp, setOtp] = useState(false);
const [, loginMutation] = useMutation(LoginMutation);
const [login] = useAsync(async (variables: LoginMutationVariables) => {
const result = await loginMutation(variables);
if (result.data) {
navigate('/dashboard');
} else if (result.error) {
if (result.error.message.toLowerCase().includes('otp')) {
setOtp(true);
}
throw result.error;
}
});
return {
login,
otpRequired: otp,
};
};
export const useLogoutUser = () => {
const [, logoutMutation] = useMutation(LogoutMutation);
const [logout] = useAsync(async () => {
@ -82,7 +52,6 @@ export const useUserRedirect = (
};
export const useUser = <T extends TypedDocumentNode<GetUserQuery, any>>(redirect?: boolean, query?: T) => {
const { login, otpRequired } = useLoginUser();
const { logout } = useLogoutUser();
const [{ data, fetching, error }] = useQuery({ query: (query || UserQuery) as T });
@ -92,8 +61,6 @@ export const useUser = <T extends TypedDocumentNode<GetUserQuery, any>>(redirect
data: data?.user as RegularUserFragment | null | undefined,
fetching: fetching,
error: error as CombinedError | undefined,
otpRequired: otpRequired,
login: login,
logout: logout,
} as const;
};