feat: アカウント作成にメールアドレス必須にするオプション (#7856)

* feat: アカウント作成にメールアドレス必須にするオプション

* ui

* fix bug

* fix bug

* fix bug

* 🎨
This commit is contained in:
syuilo 2021-10-08 13:37:02 +09:00 committed by GitHub
parent e568c3888f
commit b875cc9949
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 356 additions and 37 deletions

View file

@ -9,7 +9,7 @@
<div class="_monolithic_">
<div class="_section">
<XSignup :auto-set="autoSet" @signup="onSignup"/>
<XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
</div>
</div>
</XModalWindow>
@ -40,6 +40,10 @@ export default defineComponent({
onSignup(res) {
this.$emit('done', res);
this.$refs.dialog.close();
},
onSignupEmailPending() {
this.$refs.dialog.close();
}
}
});

View file

@ -10,13 +10,23 @@
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
<span v-if="usernameState == 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-if="usernameState == 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-if="usernameState == 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-if="usernameState == 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
<span v-if="usernameState == 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span>
<span v-if="usernameState == 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span>
<span v-if="usernameState == 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span>
<span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span>
<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span>
<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span>
</template>
</MkInput>
<MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email>
<template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template>
<template #prefix><i class="fas fa-envelope"></i></template>
<template #caption>
<span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
</template>
</MkInput>
<MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password>
@ -87,8 +97,10 @@ export default defineComponent({
password: '',
retypedPassword: '',
invitationCode: '',
email: '',
url,
usernameState: null,
emailState: null,
passwordStrength: '',
passwordRetypeState: null,
submitting: false,
@ -148,6 +160,23 @@ export default defineComponent({
});
},
onChangeEmail() {
if (this.email == '') {
this.emailState = null;
return;
}
this.emailState = 'wait';
os.api('email-address/available', {
emailAddress: this.email
}).then(result => {
this.emailState = result.available ? 'ok' : 'unavailable';
}).catch(err => {
this.emailState = 'error';
});
},
onChangePassword() {
if (this.password == '') {
this.passwordStrength = '';
@ -174,20 +203,30 @@ export default defineComponent({
os.api('signup', {
username: this.username,
password: this.password,
emailAddress: this.email,
invitationCode: this.invitationCode,
'hcaptcha-response': this.hCaptchaResponse,
'g-recaptcha-response': this.reCaptchaResponse,
}).then(() => {
return os.api('signin', {
username: this.username,
password: this.password
}).then(res => {
this.$emit('signup', res);
if (this.meta.emailRequiredForSignup) {
os.dialog({
type: 'success',
title: this.$ts._signup.almostThere,
text: this.$t('_signup.emailSent', { email: this.email }),
});
this.$emit('signupEmailPending');
} else {
os.api('signin', {
username: this.username,
password: this.password
}).then(res => {
this.$emit('signup', res);
if (this.autoSet) {
return login(res.i);
}
});
if (this.autoSet) {
login(res.i);
}
});
}
}).catch(() => {
this.submitting = false;
this.$refs.hcaptcha?.reset?.();

View file

@ -10,6 +10,8 @@
<FormSwitch v-model="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch>
<FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</FormSwitch>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
@ -50,6 +52,7 @@ export default defineComponent({
enableHcaptcha: false,
enableRecaptcha: false,
enableRegistration: false,
emailRequiredForSignup: false,
}
},
@ -63,11 +66,13 @@ export default defineComponent({
this.enableHcaptcha = meta.enableHcaptcha;
this.enableRecaptcha = meta.enableRecaptcha;
this.enableRegistration = !meta.disableRegistration;
this.emailRequiredForSignup = meta.emailRequiredForSignup;
},
save() {
os.apiWithDialog('admin/update-meta', {
disableRegistration: !this.enableRegistration,
emailRequiredForSignup: this.emailRequiredForSignup,
}).then(() => {
fetchInstance();
});

View file

@ -0,0 +1,50 @@
<template>
<div>
{{ $ts.processing }}
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@client/os';
import * as symbols from '@client/symbols';
import { login } from '@client/account';
export default defineComponent({
components: {
},
props: {
code: {
type: String,
required: true
}
},
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.signup,
icon: 'fas fa-user'
},
}
},
mounted() {
os.apiWithDialog('signup-pending', {
code: this.code,
}).then(res => {
login(res.i, '/');
});
},
methods: {
}
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -23,6 +23,7 @@ const defaultRoutes = [
{ path: '/@:acct/room', props: true, component: page('room/room') },
{ path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ initialPage: route.params.page || null }) },
{ path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) },
{ path: '/signup-complete/:code', component: page('signup-complete'), props: route => ({ code: route.params.code }) },
{ path: '/announcements', component: page('announcements') },
{ path: '/about', component: page('about') },
{ path: '/about-misskey', component: page('about-misskey') },

View file

@ -72,6 +72,7 @@ import { ChannelNotePining } from '@/models/entities/channel-note-pining';
import { RegistryItem } from '@/models/entities/registry-item';
import { Ad } from '@/models/entities/ad';
import { PasswordResetRequest } from '@/models/entities/password-reset-request';
import { UserPending } from '@/models/entities/user-pending';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -173,6 +174,7 @@ export const entities = [
RegistryItem,
Ad,
PasswordResetRequest,
UserPending,
...charts as any
];

View file

@ -148,6 +148,11 @@ export class Meta {
@JoinColumn()
public proxyAccount: User | null;
@Column('boolean', {
default: false,
})
public emailRequiredForSignup: boolean;
@Column('boolean', {
default: false,
})

View file

@ -0,0 +1,32 @@
import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
import { id } from '../id';
@Entity()
export class UserPending {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index({ unique: true })
@Column('varchar', {
length: 128,
})
public code: string;
@Column('varchar', {
length: 128,
})
public username: string;
@Column('varchar', {
length: 128,
})
public email: string;
@Column('varchar', {
length: 128,
})
public password: string;
}

View file

@ -62,6 +62,7 @@ import { ChannelNotePining } from './entities/channel-note-pining';
import { RegistryItem } from './entities/registry-item';
import { Ad } from './entities/ad';
import { PasswordResetRequest } from './entities/password-reset-request';
import { UserPending } from './entities/user-pending';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@ -76,6 +77,7 @@ export const PollVotes = getRepository(PollVote);
export const Users = getCustomRepository(UserRepository);
export const UserProfiles = getRepository(UserProfile);
export const UserKeypairs = getRepository(UserKeypair);
export const UserPendings = getRepository(UserPending);
export const AttestationChallenges = getRepository(AttestationChallenge);
export const UserSecurityKeys = getRepository(UserSecurityKey);
export const UserPublickeys = getRepository(UserPublickey);

View file

@ -11,20 +11,30 @@ import { UserKeypair } from '@/models/entities/user-keypair';
import { usersChart } from '@/services/chart/index';
import { UsedUsername } from '@/models/entities/used-username';
export async function signup(username: User['username'], password: UserProfile['password'], host: string | null = null) {
export async function signup(opts: {
username: User['username'];
password?: string | null;
passwordHash?: UserProfile['password'] | null;
host?: string | null;
}) {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
// Validate username
if (!Users.validateLocalUsername.ok(username)) {
throw new Error('INVALID_USERNAME');
}
// Validate password
if (!Users.validatePassword.ok(password)) {
throw new Error('INVALID_PASSWORD');
}
if (password != null && passwordHash == null) {
// Validate password
if (!Users.validatePassword.ok(password)) {
throw new Error('INVALID_PASSWORD');
}
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
// Generate hash of password
const salt = await bcrypt.genSalt(8);
hash = await bcrypt.hash(password, salt);
}
// Generate secret
const secret = generateUserToken();

View file

@ -35,7 +35,10 @@ export default define(meta, async (ps, _me) => {
})) === 0;
if (!noUsers && !me?.isAdmin) throw new Error('access denied');
const { account, secret } = await signup(ps.username, ps.password);
const { account, secret } = await signup({
username: ps.username,
password: ps.password,
});
const res = await Users.pack(account, account, {
detail: true,

View file

@ -93,6 +93,10 @@ export const meta = {
validator: $.optional.bool,
},
emailRequiredForSignup: {
validator: $.optional.bool,
},
enableHcaptcha: {
validator: $.optional.bool,
},
@ -374,6 +378,10 @@ export default define(meta, async (ps, me) => {
set.proxyRemoteFiles = ps.proxyRemoteFiles;
}
if (ps.emailRequiredForSignup !== undefined) {
set.emailRequiredForSignup = ps.emailRequiredForSignup;
}
if (ps.enableHcaptcha !== undefined) {
set.enableHcaptcha = ps.enableHcaptcha;
}

View file

@ -0,0 +1,37 @@
import $ from 'cafy';
import define from '../../define';
import { UserProfiles } from '@/models/index';
export const meta = {
tags: ['users'],
requireCredential: false as const,
params: {
emailAddress: {
validator: $.str
}
},
res: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
available: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
}
}
}
};
export default define(meta, async (ps) => {
const exist = await UserProfiles.count({
emailVerified: true,
email: ps.emailAddress,
});
return {
available: exist === 0
};
});

View file

@ -104,6 +104,10 @@ export const meta = {
type: 'boolean' as const,
optional: false as const, nullable: false as const
},
emailRequiredForSignup: {
type: 'boolean' as const,
optional: false as const, nullable: false as const
},
enableHcaptcha: {
type: 'boolean' as const,
optional: false as const, nullable: false as const
@ -488,6 +492,7 @@ export default define(meta, async (ps, me) => {
disableGlobalTimeline: instance.disableGlobalTimeline,
driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
emailRequiredForSignup: instance.emailRequiredForSignup,
enableHcaptcha: instance.enableHcaptcha,
hcaptchaSiteKey: instance.hcaptchaSiteKey,
enableRecaptcha: instance.enableRecaptcha,
@ -537,6 +542,7 @@ export default define(meta, async (ps, me) => {
registration: !instance.disableRegistration,
localTimeLine: !instance.disableLocalTimeline,
globalTimeLine: !instance.disableGlobalTimeline,
emailRequiredForSignup: instance.emailRequiredForSignup,
elasticsearch: config.elasticsearch ? true : false,
hcaptcha: instance.enableHcaptcha,
recaptcha: instance.enableRecaptcha,

View file

@ -12,6 +12,7 @@ import endpoints from './endpoints';
import handler from './api-handler';
import signup from './private/signup';
import signin from './private/signin';
import signupPending from './private/signup-pending';
import discord from './service/discord';
import github from './service/github';
import twitter from './service/twitter';
@ -65,6 +66,7 @@ for (const endpoint of endpoints) {
router.post('/signup', signup);
router.post('/signin', signin);
router.post('/signup-pending', signupPending);
router.use(discord.routes());
router.use(github.routes());

View file

@ -0,0 +1,35 @@
import * as Koa from 'koa';
import { Users, UserPendings, UserProfiles } from '@/models/index';
import { signup } from '../common/signup';
import signin from '../common/signin';
export default async (ctx: Koa.Context) => {
const body = ctx.request.body;
const code = body['code'];
try {
const pendingUser = await UserPendings.findOneOrFail({ code });
const { account, secret } = await signup({
username: pendingUser.username,
passwordHash: pendingUser.password,
});
UserPendings.delete({
id: pendingUser.id,
});
const profile = await UserProfiles.findOneOrFail(account.id);
await UserProfiles.update({ userId: profile.userId }, {
email: pendingUser.email,
emailVerified: true,
emailVerifyCode: null,
});
signin(ctx, account);
} catch (e) {
ctx.throw(400, e);
}
};

View file

@ -1,8 +1,13 @@
import * as Koa from 'koa';
import rndstr from 'rndstr';
import * as bcrypt from 'bcryptjs';
import { fetchMeta } from '@/misc/fetch-meta';
import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha';
import { Users, RegistrationTickets } from '@/models/index';
import { Users, RegistrationTickets, UserPendings } from '@/models/index';
import { signup } from '../common/signup';
import config from '@/config';
import { sendEmail } from '@/services/send-email';
import { genId } from '@/misc/gen-id';
export default async (ctx: Koa.Context) => {
const body = ctx.request.body;
@ -29,8 +34,16 @@ export default async (ctx: Koa.Context) => {
const password = body['password'];
const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] || null) : null;
const invitationCode = body['invitationCode'];
const emailAddress = body['emailAddress'];
if (instance && instance.disableRegistration) {
if (instance.emailRequiredForSignup) {
if (emailAddress == null || typeof emailAddress != 'string') {
ctx.status = 400;
return;
}
}
if (instance.disableRegistration) {
if (invitationCode == null || typeof invitationCode != 'string') {
ctx.status = 400;
return;
@ -48,18 +61,45 @@ export default async (ctx: Koa.Context) => {
RegistrationTickets.delete(ticket.id);
}
try {
const { account, secret } = await signup(username, password, host);
if (instance.emailRequiredForSignup) {
const code = rndstr('a-z0-9', 16);
const res = await Users.pack(account, account, {
detail: true,
includeSecrets: true
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
await UserPendings.insert({
id: genId(),
createdAt: new Date(),
code,
email: emailAddress,
username: username,
password: hash,
});
(res as any).token = secret;
const link = `${config.url}/signup-complete/${code}`;
ctx.body = res;
} catch (e) {
ctx.throw(400, e);
sendEmail(emailAddress, 'Signup',
`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
`To complete signup, please click this link: ${link}`);
ctx.status = 204;
} else {
try {
const { account, secret } = await signup({
username, password, host
});
const res = await Users.pack(account, account, {
detail: true,
includeSecrets: true
});
(res as any).token = secret;
ctx.body = res;
} catch (e) {
ctx.throw(400, e);
}
}
};

View file

@ -68,6 +68,7 @@ const nodeinfo2 = async () => {
disableRegistration: meta.disableRegistration,
disableLocalTimeline: meta.disableLocalTimeline,
disableGlobalTimeline: meta.disableGlobalTimeline,
emailRequiredForSignup: meta.emailRequiredForSignup,
enableHcaptcha: meta.enableHcaptcha,
enableRecaptcha: meta.enableRecaptcha,
maxNoteTextLength: meta.maxNoteTextLength,