Skip to content

Commit

Permalink
add: Require Approval for Signup
Browse files Browse the repository at this point in the history
  • Loading branch information
Mar0xy authored and kakkokari-gtyih committed Dec 29, 2023
1 parent 7ca0af9 commit f69b50a
Show file tree
Hide file tree
Showing 23 changed files with 278 additions and 5 deletions.
22 changes: 22 additions & 0 deletions packages/backend/migration/1697580470000-approvalSignup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class ApprovalSignup1697580470000 {
name = 'ApprovalSignup1697580470000'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "approvalRequiredForSignup" boolean DEFAULT false NOT NULL`);
await queryRunner.query(`ALTER TABLE "user" ADD "approved" boolean DEFAULT false NOT NULL`);
await queryRunner.query(`ALTER TABLE "user" ADD "signupReason" character varying(1000) NULL`);
await queryRunner.query(`ALTER TABLE "user_pending" ADD "reason" character varying(1000) NULL`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "approvalRequiredForSignup"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "approved"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "signupReason"`);
await queryRunner.query(`ALTER TABLE "user_pending" DROP COLUMN "reason"`);
}
}
10 changes: 8 additions & 2 deletions packages/backend/src/core/SignupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ export class SignupService {
password?: string | null;
passwordHash?: MiUserProfile['password'] | null;
host?: string | null;
reason?: string | null;
ignorePreservedUsernames?: boolean;
}) {
const { username, password, passwordHash, host } = opts;
const { username, password, passwordHash, host, reason } = opts;
let hash = passwordHash;
const instance = await this.metaService.fetch(true);

// Validate username
if (!this.userEntityService.validateLocalUsername(username)) {
Expand Down Expand Up @@ -84,7 +86,6 @@ export class SignupService {
const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0;

if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
const instance = await this.metaService.fetch(true);
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
if (isPreserved) {
throw new Error('USED_USERNAME');
Expand All @@ -109,6 +110,9 @@ export class SignupService {
));

let account!: MiUser;
let defaultApproval = false;

if (!instance.approvalRequiredForSignup) defaultApproval = true;

// Start transaction
await this.db.transaction(async transactionalEntityManager => {
Expand All @@ -126,6 +130,8 @@ export class SignupService {
host: this.utilityService.toPunyNullable(host),
token: secret,
isRoot: isTheFirstUser,
approved: defaultApproval,
signupReason: reason,
}));

await transactionalEntityManager.save(new MiUserKeypair({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/core/entities/UserEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,8 @@ export class UserEntityService implements OnModuleInit {
...(opts.includeSecrets ? {
email: profile!.email,
emailVerified: profile!.emailVerified,
approved: user.approved,
signupReason: user.signupReason,
securityKeysList: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.find({
where: {
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/models/Meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ export class MiMeta {
})
public emailRequiredForSignup: boolean;

@Column('boolean', {
default: false,
})
public approvalRequiredForSignup: boolean;

@Column('boolean', {
default: false,
})
Expand Down
10 changes: 10 additions & 0 deletions packages/backend/src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ export class MiUser {
})
public token: string | null;

@Column('boolean', {
default: false,
})
public approved: boolean;

@Column('varchar', {
length: 1000, nullable: true,
})
public signupReason: string | null;

constructor(data: Partial<MiUser>) {
if (data == null) return;

Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/models/UserPending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ export class MiUserPending {
length: 128,
})
public password: string;

@Column('varchar', {
length: 1000,
})
public reason: string;
}
4 changes: 4 additions & 0 deletions packages/backend/src/server/api/EndpointsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderatio
import * as ep___admin_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
import * as ep___admin_approveUser from './endpoints/admin/approve-user.js';
import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
Expand Down Expand Up @@ -430,6 +431,7 @@ const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation
const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default };
const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default };
const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default };
const $admin_approveUser: Provider = { provide: 'ep:admin/approve-user', useClass: ep___admin_approveUser.default };
const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default };
const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default };
const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default };
Expand Down Expand Up @@ -795,6 +797,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_showUser,
$admin_showUsers,
$admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser,
$admin_updateMeta,
$admin_deleteAccount,
Expand Down Expand Up @@ -1154,6 +1157,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_showUser,
$admin_showUsers,
$admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser,
$admin_updateMeta,
$admin_deleteAccount,
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/server/api/SigninApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { MetaService } from '@/core/MetaService.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
Expand All @@ -45,6 +46,7 @@ export class SigninApiService {
private signinService: SigninService,
private userAuthService: UserAuthService,
private webAuthnService: WebAuthnService,
private metaService: MetaService,
) {
}

Expand All @@ -63,6 +65,8 @@ export class SigninApiService {
reply.header('Access-Control-Allow-Origin', this.config.url);
reply.header('Access-Control-Allow-Credentials', 'true');

const instance = await this.metaService.fetch(true);

const body = request.body;
const username = body['username'];
const password = body['password'];
Expand Down Expand Up @@ -122,6 +126,17 @@ export class SigninApiService {

const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });

if (!user.approved && instance.approvalRequiredForSignup) {
reply.code(403);
return {
error: {
message: 'The account has not been approved by an admin yet. Try again later.',
code: 'NOT_APPROVED',
id: '22d05606-fbcf-421a-a2db-b32241faft1b',
},
};
}

// Compare password
const same = await bcrypt.compare(password, profile.password!);

Expand All @@ -140,6 +155,7 @@ export class SigninApiService {

if (!profile.twoFactorEnabled) {
if (same) {
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
return this.signinService.signin(request, reply, user);
} else {
return await fail(403, {
Expand All @@ -163,6 +179,8 @@ export class SigninApiService {
});
}

if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });

return this.signinService.signin(request, reply, user);
} else if (body.credential) {
if (!same && !profile.usePasswordLessLogin) {
Expand All @@ -174,6 +192,7 @@ export class SigninApiService {
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);

if (authorized) {
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
return this.signinService.signin(request, reply, user);
} else {
return await fail(403, {
Expand Down
32 changes: 32 additions & 0 deletions packages/backend/src/server/api/SignupApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { bindThis } from '@/decorators.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
import instance from './endpoints/charts/instance.js';

@Injectable()
export class SignupApiService {
Expand Down Expand Up @@ -62,6 +63,7 @@ export class SignupApiService {
host?: string;
invitationCode?: string;
emailAddress?: string;
reason?: string;
'hcaptcha-response'?: string;
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
Expand Down Expand Up @@ -99,6 +101,7 @@ export class SignupApiService {
const password = body['password'];
const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null;
const invitationCode = body['invitationCode'];
const reason = body['reason'];
const emailAddress = body['emailAddress'];

if (instance.emailRequiredForSignup) {
Expand All @@ -114,6 +117,13 @@ export class SignupApiService {
}
}

if (instance.approvalRequiredForSignup) {
if (reason == null || typeof reason !== 'string') {
reply.code(400);
return;
}
}

let ticket: MiRegistrationTicket | null = null;

if (instance.disableRegistration) {
Expand Down Expand Up @@ -182,6 +192,7 @@ export class SignupApiService {
email: emailAddress!,
username: username,
password: hash,
reason: reason,
}).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));

const link = `${this.config.url}/signup-complete/${code}`;
Expand All @@ -197,6 +208,19 @@ export class SignupApiService {
});
}

reply.code(204);
return;
} else if (instance.approvalRequiredForSignup) {
await this.signupService.signup({
username, password, host, reason,
});

if (emailAddress) {
this.emailService.sendEmail(emailAddress, 'Approval pending',
'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.',
'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.');
}

reply.code(204);
return;
} else {
Expand Down Expand Up @@ -234,6 +258,8 @@ export class SignupApiService {

const code = body['code'];

const instance = await this.metaService.fetch(true);

try {
const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code });

Expand All @@ -244,6 +270,7 @@ export class SignupApiService {
const { account, secret } = await this.signupService.signup({
username: pendingUser.username,
passwordHash: pendingUser.password,
reason: pendingUser.reason,
});

this.userPendingsRepository.delete({
Expand All @@ -266,6 +293,11 @@ export class SignupApiService {
pendingUserId: null,
});
}

if (instance.approvalRequiredForSignup) {
reply.code(204);
return;
}

return this.signinService.signin(request, reply, account as MiLocalUser);
} catch (err) {
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderatio
import * as ep___admin_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
import * as ep___admin_approveUser from './endpoints/admin/approve-user.js';
import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
Expand Down Expand Up @@ -429,6 +430,7 @@ const eps = [
['admin/show-user', ep___admin_showUser],
['admin/show-users', ep___admin_showUsers],
['admin/suspend-user', ep___admin_suspendUser],
['admin/approve-user', ep___admin_approveUser],
['admin/unsuspend-user', ep___admin_unsuspendUser],
['admin/update-meta', ep___admin_updateMeta],
['admin/delete-account', ep___admin_deleteAccount],
Expand Down
61 changes: 61 additions & 0 deletions packages/backend/src/server/api/endpoints/admin/approve-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { DI } from '@/di-symbols.js';
import { EmailService } from '@/core/EmailService.js';

export const meta = {
tags: ['admin'],

requireCredential: true,
requireModerator: true,
} as const;

export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,

@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,

private moderationLogService: ModerationLogService,
private emailService: EmailService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });

if (user == null) {
throw new Error('user not found');
}

const profile = await this.userProfilesRepository.findOneBy({ userId: ps.userId });

await this.usersRepository.update(user.id, {
approved: true,
});

if (profile?.email) {
this.emailService.sendEmail(profile.email, 'Account Approved',
'Your Account has been approved have fun socializing!',
'Your Account has been approved have fun socializing!');
}

this.moderationLogService.log(me, 'approve', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
});
}
}
Loading

0 comments on commit f69b50a

Please sign in to comment.