Password reset (#7494)
* wip * wip * Update well-known.ts * wip * clean up * Update request-reset-password.ts * Update forgot-password.vue * Update reset-password.ts * Update request-reset-password.ts
This commit is contained in:
parent
a34d8549d0
commit
6ae642245e
13 changed files with 333 additions and 3 deletions
|
@ -7,6 +7,7 @@ search: "検索"
|
||||||
notifications: "通知"
|
notifications: "通知"
|
||||||
username: "ユーザー名"
|
username: "ユーザー名"
|
||||||
password: "パスワード"
|
password: "パスワード"
|
||||||
|
forgotPassword: "パスワードを忘れた"
|
||||||
fetchingAsApObject: "連合に照会中"
|
fetchingAsApObject: "連合に照会中"
|
||||||
ok: "OK"
|
ok: "OK"
|
||||||
gotIt: "わかった"
|
gotIt: "わかった"
|
||||||
|
@ -748,6 +749,11 @@ recentPosts: "最近の投稿"
|
||||||
popularPosts: "人気の投稿"
|
popularPosts: "人気の投稿"
|
||||||
shareWithNote: "ノートで共有"
|
shareWithNote: "ノートで共有"
|
||||||
|
|
||||||
|
_forgotPassword:
|
||||||
|
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
|
||||||
|
ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。"
|
||||||
|
contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。"
|
||||||
|
|
||||||
_gallery:
|
_gallery:
|
||||||
my: "自分の投稿"
|
my: "自分の投稿"
|
||||||
liked: "いいねした投稿"
|
liked: "いいねした投稿"
|
||||||
|
|
20
migration/1619942102890-password-reset.ts
Normal file
20
migration/1619942102890-password-reset.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||||
|
|
||||||
|
export class passwordReset1619942102890 implements MigrationInterface {
|
||||||
|
name = 'passwordReset1619942102890'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "password_reset_request" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "token" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_fcf4b02eae1403a2edaf87fd074" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0b575fa9a4cfe638a925949285" ON "password_reset_request" ("token") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_4bb7fd4a34492ae0e6cc8d30ac" ON "password_reset_request" ("userId") `);
|
||||||
|
await queryRunner.query(`ALTER TABLE "password_reset_request" ADD CONSTRAINT "FK_4bb7fd4a34492ae0e6cc8d30ac8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "password_reset_request" DROP CONSTRAINT "FK_4bb7fd4a34492ae0e6cc8d30ac8"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_4bb7fd4a34492ae0e6cc8d30ac"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_0b575fa9a4cfe638a925949285"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "password_reset_request"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
71
src/client/components/forgot-password.vue
Normal file
71
src/client/components/forgot-password.vue
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<template>
|
||||||
|
<XModalWindow ref="dialog"
|
||||||
|
:width="370"
|
||||||
|
:height="400"
|
||||||
|
@close="$refs.dialog.close()"
|
||||||
|
@closed="$emit('closed')"
|
||||||
|
>
|
||||||
|
<template #header>{{ $ts.forgotPassword }}</template>
|
||||||
|
|
||||||
|
<form class="_monolithic_" @submit.prevent="onSubmit" v-if="$instance.enableEmail">
|
||||||
|
<div class="_section">
|
||||||
|
<MkInput v-model:value="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required>
|
||||||
|
<span>{{ $ts.username }}</span>
|
||||||
|
<template #prefix>@</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
|
<MkInput v-model:value="email" type="email" spellcheck="false" required>
|
||||||
|
<span>{{ $ts.emailAddress }}</span>
|
||||||
|
<template #desc>{{ $ts._forgotPassword.enterEmail }}</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
|
<MkButton type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton>
|
||||||
|
</div>
|
||||||
|
<div class="_section">
|
||||||
|
<MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div v-else>
|
||||||
|
{{ $ts._forgotPassword.contactAdmin }}
|
||||||
|
</div>
|
||||||
|
</XModalWindow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import XModalWindow from '@client/components/ui/modal-window.vue';
|
||||||
|
import MkButton from '@client/components/ui/button.vue';
|
||||||
|
import MkInput from '@client/components/ui/input.vue';
|
||||||
|
import * as os from '@client/os';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
XModalWindow,
|
||||||
|
MkButton,
|
||||||
|
MkInput,
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['done', 'closed'],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
processing: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async onSubmit() {
|
||||||
|
this.processing = true;
|
||||||
|
await os.apiWithDialog('request-reset-password', {
|
||||||
|
username: this.username,
|
||||||
|
email: this.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$emit('done');
|
||||||
|
this.$refs.dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -11,6 +11,7 @@
|
||||||
<MkInput v-model:value="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required>
|
<MkInput v-model:value="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required>
|
||||||
<span>{{ $ts.password }}</span>
|
<span>{{ $ts.password }}</span>
|
||||||
<template #prefix><i class="fas fa-lock"></i></template>
|
<template #prefix><i class="fas fa-lock"></i></template>
|
||||||
|
<template #desc><button class="_textButton" @click="resetPassword">{{ $ts.forgotPassword }}</button></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkButton type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
|
<MkButton type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -49,8 +50,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { toUnicode } from 'punycode/';
|
import { toUnicode } from 'punycode/';
|
||||||
import MkButton from './ui/button.vue';
|
import MkButton from '@client/components/ui/button.vue';
|
||||||
import MkInput from './ui/input.vue';
|
import MkInput from '@client/components/ui/input.vue';
|
||||||
import { apiUrl, host } from '@client/config';
|
import { apiUrl, host } from '@client/config';
|
||||||
import { byteify, hexify } from '@client/scripts/2fa';
|
import { byteify, hexify } from '@client/scripts/2fa';
|
||||||
import * as os from '@client/os';
|
import * as os from '@client/os';
|
||||||
|
@ -197,6 +198,11 @@ export default defineComponent({
|
||||||
this.signing = false;
|
this.signing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPassword() {
|
||||||
|
os.popup(import('@client/components/forgot-password.vue'), {}, {
|
||||||
|
}, 'closed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
69
src/client/pages/reset-password.vue
Normal file
69
src/client/pages/reset-password.vue
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<FormBase v-if="token">
|
||||||
|
<FormInput v-model:value="password" type="password">
|
||||||
|
<template #prefix><i class="fas fa-lock"></i></template>
|
||||||
|
<span>{{ $ts.newPassword }}</span>
|
||||||
|
</FormInput>
|
||||||
|
|
||||||
|
<FormButton primary @click="save">{{ $ts.save }}</FormButton>
|
||||||
|
</FormBase>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import FormLink from '@client/components/form/link.vue';
|
||||||
|
import FormBase from '@client/components/form/base.vue';
|
||||||
|
import FormGroup from '@client/components/form/group.vue';
|
||||||
|
import FormInput from '@client/components/form/input.vue';
|
||||||
|
import FormButton from '@client/components/form/button.vue';
|
||||||
|
import * as os from '@client/os';
|
||||||
|
import * as symbols from '@client/symbols';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
FormBase,
|
||||||
|
FormGroup,
|
||||||
|
FormLink,
|
||||||
|
FormInput,
|
||||||
|
FormButton,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
token: {
|
||||||
|
type: String,
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
[symbols.PAGE_INFO]: {
|
||||||
|
title: this.$ts.resetPassword,
|
||||||
|
icon: 'fas fa-lock'
|
||||||
|
},
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
if (this.token == null) {
|
||||||
|
os.popup(import('@client/components/forgot-password.vue'), {}, {}, 'closed');
|
||||||
|
this.$router.push('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async save() {
|
||||||
|
await os.apiWithDialog('reset-password', {
|
||||||
|
token: this.token,
|
||||||
|
password: this.password,
|
||||||
|
});
|
||||||
|
this.$router.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -23,6 +23,7 @@ export const router = createRouter({
|
||||||
{ path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) },
|
{ path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) },
|
||||||
{ path: '/@:acct/room', props: true, component: page('room/room') },
|
{ 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: '/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: '/announcements', component: page('announcements') },
|
{ path: '/announcements', component: page('announcements') },
|
||||||
{ path: '/about', component: page('about') },
|
{ path: '/about', component: page('about') },
|
||||||
{ path: '/about-misskey', component: page('about-misskey') },
|
{ path: '/about-misskey', component: page('about-misskey') },
|
||||||
|
|
|
@ -337,7 +337,7 @@ hr {
|
||||||
}
|
}
|
||||||
|
|
||||||
._monolithic_ {
|
._monolithic_ {
|
||||||
._section {
|
._section:not(:empty) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: var(--root-margin, 32px);
|
padding: var(--root-margin, 32px);
|
||||||
|
|
||||||
|
|
|
@ -70,6 +70,7 @@ import { Channel } from '../models/entities/channel';
|
||||||
import { ChannelFollowing } from '../models/entities/channel-following';
|
import { ChannelFollowing } from '../models/entities/channel-following';
|
||||||
import { ChannelNotePining } from '../models/entities/channel-note-pining';
|
import { ChannelNotePining } from '../models/entities/channel-note-pining';
|
||||||
import { RegistryItem } from '../models/entities/registry-item';
|
import { RegistryItem } from '../models/entities/registry-item';
|
||||||
|
import { PasswordResetRequest } from '@/models/entities/password-reset-request';
|
||||||
|
|
||||||
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
|
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
|
||||||
|
|
||||||
|
@ -169,6 +170,7 @@ export const entities = [
|
||||||
ChannelFollowing,
|
ChannelFollowing,
|
||||||
ChannelNotePining,
|
ChannelNotePining,
|
||||||
RegistryItem,
|
RegistryItem,
|
||||||
|
PasswordResetRequest,
|
||||||
...charts as any
|
...charts as any
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
30
src/models/entities/password-reset-request.ts
Normal file
30
src/models/entities/password-reset-request.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { id } from '../id';
|
||||||
|
import { User } from './user';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class PasswordResetRequest {
|
||||||
|
@PrimaryColumn(id())
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@Column('timestamp with time zone')
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 256,
|
||||||
|
})
|
||||||
|
public token: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({
|
||||||
|
...id(),
|
||||||
|
})
|
||||||
|
public userId: User['id'];
|
||||||
|
|
||||||
|
@ManyToOne(type => User, {
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public user: User | null;
|
||||||
|
}
|
|
@ -60,6 +60,7 @@ import { MutedNote } from './entities/muted-note';
|
||||||
import { ChannelFollowing } from './entities/channel-following';
|
import { ChannelFollowing } from './entities/channel-following';
|
||||||
import { ChannelNotePining } from './entities/channel-note-pining';
|
import { ChannelNotePining } from './entities/channel-note-pining';
|
||||||
import { RegistryItem } from './entities/registry-item';
|
import { RegistryItem } from './entities/registry-item';
|
||||||
|
import { PasswordResetRequest } from './entities/password-reset-request';
|
||||||
|
|
||||||
export const Announcements = getRepository(Announcement);
|
export const Announcements = getRepository(Announcement);
|
||||||
export const AnnouncementReads = getRepository(AnnouncementRead);
|
export const AnnouncementReads = getRepository(AnnouncementRead);
|
||||||
|
@ -122,3 +123,4 @@ export const Channels = getCustomRepository(ChannelRepository);
|
||||||
export const ChannelFollowings = getRepository(ChannelFollowing);
|
export const ChannelFollowings = getRepository(ChannelFollowing);
|
||||||
export const ChannelNotePinings = getRepository(ChannelNotePining);
|
export const ChannelNotePinings = getRepository(ChannelNotePining);
|
||||||
export const RegistryItems = getRepository(RegistryItem);
|
export const RegistryItems = getRepository(RegistryItem);
|
||||||
|
export const PasswordResetRequests = getRepository(PasswordResetRequest);
|
||||||
|
|
73
src/server/api/endpoints/request-reset-password.ts
Normal file
73
src/server/api/endpoints/request-reset-password.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import $ from 'cafy';
|
||||||
|
import { publishMainStream } from '../../../services/stream';
|
||||||
|
import define from '../define';
|
||||||
|
import rndstr from 'rndstr';
|
||||||
|
import config from '@/config';
|
||||||
|
import * as ms from 'ms';
|
||||||
|
import { Users, UserProfiles, PasswordResetRequests } from '../../../models';
|
||||||
|
import { sendEmail } from '../../../services/send-email';
|
||||||
|
import { ApiError } from '../error';
|
||||||
|
import { genId } from '@/misc/gen-id';
|
||||||
|
import { IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: false as const,
|
||||||
|
|
||||||
|
limit: {
|
||||||
|
duration: ms('1hour'),
|
||||||
|
max: 3
|
||||||
|
},
|
||||||
|
|
||||||
|
params: {
|
||||||
|
username: {
|
||||||
|
validator: $.str
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
validator: $.str
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default define(meta, async (ps) => {
|
||||||
|
const user = await Users.findOne({
|
||||||
|
usernameLower: ps.username.toLowerCase(),
|
||||||
|
host: IsNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 合致するユーザーが登録されていなかったら無視
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await UserProfiles.findOneOrFail(user.id);
|
||||||
|
|
||||||
|
// 合致するメアドが登録されていなかったら無視
|
||||||
|
if (profile.email !== ps.email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// メアドが認証されていなかったら無視
|
||||||
|
if (!profile.emailVerified) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = rndstr('a-z0-9', 64);
|
||||||
|
|
||||||
|
await PasswordResetRequests.insert({
|
||||||
|
id: genId(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
userId: profile.userId,
|
||||||
|
token
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = `${config.url}/reset-password/${token}`;
|
||||||
|
|
||||||
|
sendEmail(ps.email, 'Password reset requested',
|
||||||
|
`To reset password, please click this link:<br><a href="${link}">${link}</a>`,
|
||||||
|
`To reset password, please click this link: ${link}`);
|
||||||
|
});
|
45
src/server/api/endpoints/reset-password.ts
Normal file
45
src/server/api/endpoints/reset-password.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import $ from 'cafy';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { publishMainStream } from '../../../services/stream';
|
||||||
|
import define from '../define';
|
||||||
|
import { Users, UserProfiles, PasswordResetRequests } from '../../../models';
|
||||||
|
import { ApiError } from '../error';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: false as const,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
token: {
|
||||||
|
validator: $.str
|
||||||
|
},
|
||||||
|
|
||||||
|
password: {
|
||||||
|
validator: $.str
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default define(meta, async (ps, user) => {
|
||||||
|
const req = await PasswordResetRequests.findOneOrFail({
|
||||||
|
token: ps.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 発行してから30分以上経過していたら無効
|
||||||
|
if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) {
|
||||||
|
throw new Error(); // TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate hash of password
|
||||||
|
const salt = await bcrypt.genSalt(8);
|
||||||
|
const hash = await bcrypt.hash(ps.password, salt);
|
||||||
|
|
||||||
|
await UserProfiles.update(req.userId, {
|
||||||
|
password: hash
|
||||||
|
});
|
||||||
|
|
||||||
|
PasswordResetRequests.delete(req.id);
|
||||||
|
});
|
|
@ -61,6 +61,11 @@ router.get('/.well-known/nodeinfo', async ctx => {
|
||||||
ctx.body = { links };
|
ctx.body = { links };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* TODO
|
||||||
|
router.get('/.well-known/change-password', async ctx => {
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
router.get(webFingerPath, async ctx => {
|
router.get(webFingerPath, async ctx => {
|
||||||
const fromId = (id: User['id']): Record<string, any> => ({
|
const fromId = (id: User['id']): Record<string, any> => ({
|
||||||
id,
|
id,
|
||||||
|
|
Loading…
Reference in a new issue