Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance: ログイン試行時/失敗時の通知&メール送信 #15263

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Open
18 changes: 18 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9460,6 +9460,20 @@ export interface Locale extends ILocale {
* ログインがありました
*/
"login": string;
/**
* {ip}からログインされました。
* 心当たりがない場合、設定の「{text}」からすべての機器をログアウトしてください。
*/
"loginDescription": ParameterizedString<"ip" | "text">;
/**
* ログインに失敗しました
*/
"loginFailed": string;
/**
* {ip}からのログインに失敗しました。
* 心当たりがない場合はパスワードの変更を行ってください。
*/
"loginFailedDescription": ParameterizedString<"ip">;
"_types": {
/**
* すべて
Expand Down Expand Up @@ -9521,6 +9535,10 @@ export interface Locale extends ILocale {
* ログイン
*/
"login": string;
/**
* ログインに失敗
*/
"loginFailed": string;
/**
* 通知のテスト
*/
Expand Down
4 changes: 4 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2497,6 +2497,9 @@ _notification:
flushNotification: "通知の履歴をリセットする"
exportOfXCompleted: "{x}のエクスポートが完了しました"
login: "ログインがありました"
loginDescription: "{ip}からログインされました。\n心当たりがない場合、設定の「{text}」からすべての機器をログアウトしてください。"
loginFailed: "ログインに失敗しました"
loginFailedDescription: "{ip}からのログインに失敗しました。\n心当たりがない場合はパスワードの変更を行ってください。"

_types:
all: "すべて"
Expand All @@ -2514,6 +2517,7 @@ _notification:
achievementEarned: "実績の獲得"
exportCompleted: "エクスポートが完了した"
login: "ログイン"
loginFailed: "ログインに失敗"
test: "通知のテスト"
app: "連携アプリからの通知"

Expand Down
10 changes: 8 additions & 2 deletions packages/backend/src/core/entities/NotificationEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit {
async #packInternal <T extends MiNotification | MiGroupedNotification> (
src: T,
meId: MiUser['id'],

options: {
checkValidNotifier?: boolean;
},
Expand Down Expand Up @@ -169,6 +169,12 @@ export class NotificationEntityService implements OnModuleInit {
exportedEntity: notification.exportedEntity,
fileId: notification.fileId,
} : {}),
...(notification.type === 'login' ? {
ip: notification.userIp,
} : {}),
...(notification.type === 'loginFailed' ? {
ip: notification.userIp,
} : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader,
Expand Down Expand Up @@ -236,7 +242,7 @@ export class NotificationEntityService implements OnModuleInit {
public async pack(
src: MiNotification | MiGroupedNotification,
meId: MiUser['id'],

options: {
checkValidNotifier?: boolean;
},
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/models/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export type MiNotification = {
type: 'login';
id: string;
createdAt: string;
userIp: string;
} | {
type: 'loginFailed';
id: string;
createdAt: string;
userIp: string;
} | {
type: 'app';
id: string;
Expand Down
18 changes: 18 additions & 0 deletions packages/backend/src/models/json-schema/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,24 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
enum: ['login'],
},
ip: {
type: 'string',
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['loginFailed'],
},
ip: {
type: 'string',
optional: false, nullable: false,
},
},
}, {
type: 'object',
Expand Down
22 changes: 22 additions & 0 deletions packages/backend/src/server/api/SigninApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import { WebAuthnService } from '@/core/WebAuthnService.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { NotificationService } from '@/core/NotificationService.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
import { EmailService } from '@/core/EmailService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify';

Expand Down Expand Up @@ -53,9 +55,11 @@ export class SigninApiService {
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
private emailService: EmailService,
private userAuthService: UserAuthService,
private webAuthnService: WebAuthnService,
private captchaService: CaptchaService,
private notificationService: NotificationService,
) {
}

Expand Down Expand Up @@ -167,6 +171,24 @@ export class SigninApiService {
success: false,
});

// ログインに失敗したことを通知
await this.notificationService.createNotification(user.id, 'loginFailed', {
userIp: request.ip,
});

const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, 'Login failed / ログインに失敗しました',
`userid: ${user.name ?? `@${user.username}`} <br>` +
chan-mai marked this conversation as resolved.
Show resolved Hide resolved
`ip: ${request.ip} <br>` +
'header: ' + JSON.stringify(request.headers) + '<br>' +
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。',
`userid: ${user.name ?? `@${user.username}`} \n` +
`ip: ${request.ip} \n` +
'header: ' + JSON.stringify(request.headers) + '\n' +
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。');
}

return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
};

Expand Down
10 changes: 9 additions & 1 deletion packages/backend/src/server/api/SigninService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export class SigninService {
@bindThis
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
setImmediate(async () => {
this.notificationService.createNotification(user.id, 'login', {});
this.notificationService.createNotification(user.id, 'login', {
userIp: request.ip,
});

const record = await this.signinsRepository.insertOne({
id: this.idService.gen(),
Expand All @@ -51,7 +53,13 @@ export class SigninService {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, 'New login / ログインがありました',
`userid: ${user.name ?? `@${user.username}`} <br>` +
`ip: ${request.ip} <br>` +
'header: ' + JSON.stringify(request.headers) + '<br>' +
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。',
`userid: ${user.name ?? `@${user.username}`} \n` +
`ip: ${request.ip} \n` +
'header: ' + JSON.stringify(request.headers) + '\n' +
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。');
}
});
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* achievementEarned - 実績を獲得
* exportCompleted - エクスポートが完了
* login - ログイン
* loginFailed - ログインに失敗
* app - アプリ通知
* test - テスト通知(サーバー側)
*/
Expand All @@ -36,6 +37,7 @@ export const notificationTypes = [
'achievementEarned',
'exportCompleted',
'login',
'loginFailed',
'app',
'test',
] as const;
Expand Down
1 change: 1 addition & 0 deletions packages/frontend-shared/js/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const notificationTypes = [
'achievementEarned',
'exportCompleted',
'login',
'loginFailed',
'test',
'app',
] as const;
Expand Down
18 changes: 17 additions & 1 deletion packages/frontend/src/components/MkNotification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root">
<div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'loginFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
Expand All @@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_login]: notification.type === 'login',
[$style.t_loginFailed]: notification.type === 'loginFailed',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
}]"
>
Expand All @@ -41,6 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
<i v-else-if="notification.type === 'loginFailed'" class="ti ti-lock-exclamation"></i>
<template v-else-if="notification.type === 'roleAssigned'">
<img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/>
<i v-else class="ti ti-badges"></i>
Expand All @@ -61,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span>
<span v-else-if="notification.type === 'loginFailed'">{{ i18n.ts._notification.loginFailed }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
Expand Down Expand Up @@ -107,6 +110,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
{{ i18n.ts.showFile }}
</MkA>
<MkA v-else-if="notification.type === 'loginFailed'" :class="$style.text" to="/settings/security">
<Mfm :text="i18n.tsx._notification.loginFailedDescription({ ip: notification.ip })"/>
</MkA>
<MkA v-else-if="notification.type === 'login'" :class="$style.text" to="/settings/security">
<Mfm :text="i18n.tsx._notification.loginDescription({ ip: notification.ip, text: i18n.ts.regenerateLoginToken })"/>
</MkA>
<template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
</template>
Expand Down Expand Up @@ -357,6 +366,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none;
}

.t_loginFailed {
padding: 3px;
background: var(--eventUnFollow);
pointer-events: none;
}

.tail {
flex: 1;
min-width: 0;
Expand Down Expand Up @@ -384,6 +399,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
display: flex;
width: 100%;
overflow: clip;
opacity: 0.7;
}

.quote {
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/pages/settings/notifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const $i = signinRequired();

const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[];

const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login'] satisfies (typeof notificationTypes[number])[] as string[];
const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'loginFailed'] satisfies (typeof notificationTypes[number])[] as string[];

const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer);
Expand Down
17 changes: 13 additions & 4 deletions packages/misskey-js/src/autogen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4328,6 +4328,15 @@ export type components = {
createdAt: string;
/** @enum {string} */
type: 'login';
ip: string;
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'loginFailed';
ip: string;
} | ({
/** Format: id */
id: string;
Expand Down Expand Up @@ -18781,8 +18790,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'loginFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'loginFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
Expand Down Expand Up @@ -18849,8 +18858,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'loginFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'loginFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
};
};
};
Expand Down
8 changes: 8 additions & 0 deletions packages/sw/src/scripts/create-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif

case 'login':
return [i18n.ts._notification.login, {
body: i18n.tsx._notification.loginDescription({ ip: data.body.ip, text: i18n.ts.regenerateLoginToken }),
badge: iconUrl('login-2'),
data,
}];

case 'loginFailed':
return [i18n.ts._notification.loginFailed, {
body: i18n.tsx._notification.loginFailedDescription({ ip: data.body.ip }),
badge: iconUrl('login-2'),
data,
}];
Expand Down
Loading