summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-05-04 15:05:34 +0900
committerGitHub <noreply@github.com>2021-05-04 15:05:34 +0900
commit6ae642245e0322f194ca5d960f669f33ba38c2fa (patch)
treef71ac2c1a2d0aa616d4e99d37c00787219c5e3d0
parentFix style (diff)
downloadsharkey-6ae642245e0322f194ca5d960f669f33ba38c2fa.tar.gz
sharkey-6ae642245e0322f194ca5d960f669f33ba38c2fa.tar.bz2
sharkey-6ae642245e0322f194ca5d960f669f33ba38c2fa.zip
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
-rw-r--r--locales/ja-JP.yml6
-rw-r--r--migration/1619942102890-password-reset.ts20
-rw-r--r--src/client/components/forgot-password.vue71
-rwxr-xr-xsrc/client/components/signin.vue10
-rw-r--r--src/client/pages/reset-password.vue69
-rw-r--r--src/client/router.ts1
-rw-r--r--src/client/style.scss2
-rw-r--r--src/db/postgre.ts2
-rw-r--r--src/models/entities/password-reset-request.ts30
-rw-r--r--src/models/index.ts2
-rw-r--r--src/server/api/endpoints/request-reset-password.ts73
-rw-r--r--src/server/api/endpoints/reset-password.ts45
-rw-r--r--src/server/well-known.ts5
13 files changed, 333 insertions, 3 deletions
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 041bdfb11d..2b973ae55f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -7,6 +7,7 @@ search: "検索"
notifications: "通知"
username: "ユーザー名"
password: "パスワード"
+forgotPassword: "パスワードを忘れた"
fetchingAsApObject: "連合に照会中"
ok: "OK"
gotIt: "わかった"
@@ -748,6 +749,11 @@ recentPosts: "最近の投稿"
popularPosts: "人気の投稿"
shareWithNote: "ノートで共有"
+_forgotPassword:
+ enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
+ ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。"
+ contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。"
+
_gallery:
my: "自分の投稿"
liked: "いいねした投稿"
diff --git a/migration/1619942102890-password-reset.ts b/migration/1619942102890-password-reset.ts
new file mode 100644
index 0000000000..66854cb025
--- /dev/null
+++ b/migration/1619942102890-password-reset.ts
@@ -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"`);
+ }
+
+}
diff --git a/src/client/components/forgot-password.vue b/src/client/components/forgot-password.vue
new file mode 100644
index 0000000000..1f530d7ca2
--- /dev/null
+++ b/src/client/components/forgot-password.vue
@@ -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>
diff --git a/src/client/components/signin.vue b/src/client/components/signin.vue
index 2c883e0c32..f8249ffcd6 100755
--- a/src/client/components/signin.vue
+++ b/src/client/components/signin.vue
@@ -11,6 +11,7 @@
<MkInput v-model:value="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required>
<span>{{ $ts.password }}</span>
<template #prefix><i class="fas fa-lock"></i></template>
+ <template #desc><button class="_textButton" @click="resetPassword">{{ $ts.forgotPassword }}</button></template>
</MkInput>
<MkButton type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
</div>
@@ -49,8 +50,8 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { toUnicode } from 'punycode/';
-import MkButton from './ui/button.vue';
-import MkInput from './ui/input.vue';
+import MkButton from '@client/components/ui/button.vue';
+import MkInput from '@client/components/ui/input.vue';
import { apiUrl, host } from '@client/config';
import { byteify, hexify } from '@client/scripts/2fa';
import * as os from '@client/os';
@@ -197,6 +198,11 @@ export default defineComponent({
this.signing = false;
});
}
+ },
+
+ resetPassword() {
+ os.popup(import('@client/components/forgot-password.vue'), {}, {
+ }, 'closed');
}
}
});
diff --git a/src/client/pages/reset-password.vue b/src/client/pages/reset-password.vue
new file mode 100644
index 0000000000..c331382132
--- /dev/null
+++ b/src/client/pages/reset-password.vue
@@ -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>
diff --git a/src/client/router.ts b/src/client/router.ts
index 8dcc1d1eb4..4c3aa765e6 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -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: '/@: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: '/announcements', component: page('announcements') },
{ path: '/about', component: page('about') },
{ path: '/about-misskey', component: page('about-misskey') },
diff --git a/src/client/style.scss b/src/client/style.scss
index aa00303a15..523ab13034 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -337,7 +337,7 @@ hr {
}
._monolithic_ {
- ._section {
+ ._section:not(:empty) {
box-sizing: border-box;
padding: var(--root-margin, 32px);
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index c8b0121719..e2a779a52d 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -70,6 +70,7 @@ import { Channel } from '../models/entities/channel';
import { ChannelFollowing } from '../models/entities/channel-following';
import { ChannelNotePining } from '../models/entities/channel-note-pining';
import { RegistryItem } from '../models/entities/registry-item';
+import { PasswordResetRequest } from '@/models/entities/password-reset-request';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@@ -169,6 +170,7 @@ export const entities = [
ChannelFollowing,
ChannelNotePining,
RegistryItem,
+ PasswordResetRequest,
...charts as any
];
diff --git a/src/models/entities/password-reset-request.ts b/src/models/entities/password-reset-request.ts
new file mode 100644
index 0000000000..6d41d38a93
--- /dev/null
+++ b/src/models/entities/password-reset-request.ts
@@ -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;
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index 9d08e49858..6ce453ef33 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -60,6 +60,7 @@ import { MutedNote } from './entities/muted-note';
import { ChannelFollowing } from './entities/channel-following';
import { ChannelNotePining } from './entities/channel-note-pining';
import { RegistryItem } from './entities/registry-item';
+import { PasswordResetRequest } from './entities/password-reset-request';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@@ -122,3 +123,4 @@ export const Channels = getCustomRepository(ChannelRepository);
export const ChannelFollowings = getRepository(ChannelFollowing);
export const ChannelNotePinings = getRepository(ChannelNotePining);
export const RegistryItems = getRepository(RegistryItem);
+export const PasswordResetRequests = getRepository(PasswordResetRequest);
diff --git a/src/server/api/endpoints/request-reset-password.ts b/src/server/api/endpoints/request-reset-password.ts
new file mode 100644
index 0000000000..c880df7527
--- /dev/null
+++ b/src/server/api/endpoints/request-reset-password.ts
@@ -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}`);
+});
diff --git a/src/server/api/endpoints/reset-password.ts b/src/server/api/endpoints/reset-password.ts
new file mode 100644
index 0000000000..5f79bdbd00
--- /dev/null
+++ b/src/server/api/endpoints/reset-password.ts
@@ -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);
+});
diff --git a/src/server/well-known.ts b/src/server/well-known.ts
index b1b6b2a771..57b6aba9a0 100644
--- a/src/server/well-known.ts
+++ b/src/server/well-known.ts
@@ -61,6 +61,11 @@ router.get('/.well-known/nodeinfo', async ctx => {
ctx.body = { links };
});
+/* TODO
+router.get('/.well-known/change-password', async ctx => {
+});
+*/
+
router.get(webFingerPath, async ctx => {
const fromId = (id: User['id']): Record<string, any> => ({
id,