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

Add Sign in with passkey Button #14577

Merged
merged 15 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### General
- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445)
- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように
- Feat: パスキーでログインボタンを実装 (#14574)

### Client
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
Expand Down
5 changes: 5 additions & 0 deletions locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,11 @@ confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media"
sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?"
createdLists: "Created lists"
createdAntennas: "Created antennas"
signinWithPasskey: "Login with passkey"
unknownWebAuthnKey: "It is not authenticated passkey."
verificationFailed: "Failed to verificate passkey."
passwordlessLoginDisabled: "Passkey verification successful, but the passwordless login was not enabled."

_delivery:
status: "Delivery status"
stop: "Suspended"
Expand Down
16 changes: 16 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5108,6 +5108,22 @@ export interface Locale extends ILocale {
* {n}件の変更があります
*/
"thereAreNChanges": ParameterizedString<"n">;
/**
* パスキーでログイン
*/
"signinWithPasskey": string;
/**
* 登録していないパスキーです。
*/
"unknownWebAuthnKey": string;
/**
* パスキー検証に失敗しました。
*/
"verificationFailed": string;
/**
* パスキー検証には成功しましたが、パスワードレスログインが無効にしています。
*/
"passwordlessLoginDisabled": string;
"_delivery": {
/**
* 配信状態
Expand Down
4 changes: 4 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,10 @@ performance: "パフォーマンス"
modified: "変更あり"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
signinWithPasskey: "パスキーでログイン"
unknownWebAuthnKey: "登録していないパスキーです。"
verificationFailed: "パスキー検証に失敗しました。"
passwordlessLoginDisabled: "パスキー検証には成功しましたが、パスワードレスログインが無効にしています。"
yunochi marked this conversation as resolved.
Show resolved Hide resolved

_delivery:
status: "配信状態"
Expand Down
5 changes: 5 additions & 0 deletions locales/ko-KR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,11 @@ alwaysConfirmFollow: "팔로우일 때 항상 확인하기"
inquiry: "문의하기"
tryAgain: "다시 시도해 주세요."
confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인"
signinWithPasskey: "패스키로 로그인"
unknownWebAuthnKey: "등록되지 않은 패스키 입니다."
verificationFailed: "패스키 검증이 실패했습니다."
passwordlessLoginDisabled: "인증에는 성공했지만, 비밀번호 없이 로그인 설정이 활성화 되어있지 않습니다."

_delivery:
status: "전송 상태"
stop: "정지됨"
Expand Down
80 changes: 80 additions & 0 deletions packages/backend/src/core/WebAuthnService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,86 @@ export class WebAuthnService {
return authenticationOptions;
}

/**
* Initiate Passkey Auth (Without specifying user)
* @returns authenticationOptions
*/
@bindThis
public async initiateSignInWithPasskeyAuthentication(context: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
const relyingParty = await this.getRelyingParty();

const authenticationOptions = await generateAuthenticationOptions({
rpID: relyingParty.rpId,
userVerification: 'preferred',
});

await this.redisClient.setex(`webauthn:challenge:${context}`, 90, authenticationOptions.challenge);

return authenticationOptions;
}

/**
* Verify Webauthn AuthenticationCredential
* @throws IdentifiableError
* @returns If the challenge is successful, return the user ID. Otherwise, return null.
*/
@bindThis
public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise<MiUser['id'] | null> {
const challenge = await this.redisClient.get(`webauthn:challenge:${context}`);

if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`);
}

await this.redisClient.del(`webauthn:challenge:${context}`);

const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
});

if (!key) {
throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'Unknown Webauthn key');
}

const relyingParty = await this.getRelyingParty();

let verification;
try {
verification = await verifyAuthenticationResponse({
response: response,
expectedChallenge: challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
authenticator: {
credentialID: key.id,
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
counter: key.counter,
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
},
requireUserVerification: true,
});
} catch (error) {
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`);
}

const { verified, authenticationInfo } = verification;

if (!verified) {
return null;
}

await this.userSecurityKeysRepository.update({
id: response.id,
}, {
lastUsed: new Date(),
counter: authenticationInfo.newCounter,
credentialDeviceType: authenticationInfo.credentialDeviceType,
credentialBackedUp: authenticationInfo.credentialBackedUp,
});

return key.userId;
}

@bindThis
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/ServerModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';

@Module({
imports: [
Expand All @@ -71,6 +72,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
AuthenticateService,
RateLimiterService,
SigninApiService,
SigninWithPasskeyApiService,
SigninService,
SignupApiService,
StreamingApiServerService,
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/server/api/ApiServerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import { ModuleRef } from '@nestjs/core';
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { Config } from '@/config.js';
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
Expand All @@ -17,6 +18,7 @@ import endpoints from './endpoints.js';
import { ApiCallService } from './ApiCallService.js';
import { SignupApiService } from './SignupApiService.js';
import { SigninApiService } from './SigninApiService.js';
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';

@Injectable()
Expand All @@ -37,6 +39,7 @@ export class ApiServerService {
private apiCallService: ApiCallService,
private signupApiService: SignupApiService,
private signinApiService: SigninApiService,
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
) {
//this.createServer = this.createServer.bind(this);
}
Expand Down Expand Up @@ -131,6 +134,12 @@ export class ApiServerService {
};
}>('/signin', (request, reply) => this.signinApiService.signin(request, reply));

fastify.post<{
Body: {
credential?: AuthenticationResponseJSON;
};
}>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply));

fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply));

fastify.get('/v1/instance/peers', async (request, reply) => {
Expand Down
173 changes: 173 additions & 0 deletions packages/backend/src/server/api/SigninWithPasskeyApiService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { randomUUID } from 'crypto';
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type {
SigninsRepository,
UserProfilesRepository,
UsersRepository,
} from '@/models/_.js';
import type { Config } from '@/config.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IdentifiableError } from '@/misc/identifiable-error.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify';

@Injectable()
export class SigninWithPasskeyApiService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,

@Inject(DI.usersRepository)
private usersRepository: UsersRepository,

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

@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,

private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
private webAuthnService: WebAuthnService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('PasskeyAuth');
}

@bindThis
public async signin(
request: FastifyRequest<{
Body: {
credential?: AuthenticationResponseJSON;
context?: string;
};
}>,
reply: FastifyReply,
) {
reply.header('Access-Control-Allow-Origin', this.config.url);
reply.header('Access-Control-Allow-Credentials', 'true');

const body = request.body;
const credential = body['credential'];

function error(status: number, error: { id: string }) {
reply.code(status);
return { error };
}

const fail = async (userId: MiUser['id'], status?: number, failure?: { id: string }) => {
// Append signin history
await this.signinsRepository.insert({
id: this.idService.gen(),
userId: userId,
ip: request.ip,
headers: request.headers as any,
success: false,
});
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
};

try {
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
// NOTE: 1 Sign-in require 2 API calls
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}

// Initiate Passkey Auth challenge with context
if (!credential) {
const context = randomUUID();
this.logger.info(`Initiate Passkey challenge: context: ${context}`);
const authChallengeOptions = {
option: await this.webAuthnService.initiateSignInWithPasskeyAuthentication(context),
context: context,
};
reply.code(200);
return authChallengeOptions;
}

const context = body.context;
if (!context || typeof context !== 'string') {
// If try Authentication without context
return error(400, {
id: '1658cc2e-4495-461f-aee4-d403cdf073c1',
});
}

this.logger.debug(`Try Sign-in with Passkey: context: ${context}`);

let authorizedUserId: MiUser['id'] | null;
try {
authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential);
} catch (err) {
this.logger.warn(`Passkey challenge Verify error! : ${err}`);
const errorId = (err as IdentifiableError).id;
return error(403, {
id: errorId,
});
}

if (!authorizedUserId) {
return error(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
}

// Fetch user
const user = await this.usersRepository.findOneBy({
id: authorizedUserId,
host: IsNull(),
}) as MiLocalUser | null;

if (user == null) {
return error(403, {
id: '652f899f-66d4-490e-993e-6606c8ec04c3',
});
}

if (user.isSuspended) {
return error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
});
}

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

// Authentication was successful, but passwordless login is not enabled
if (!profile.usePasswordLessLogin) {
return await fail(user.id, 403, {
id: '2d84773e-f7b7-4d0b-8f72-bb69b584c912',
});
}

const signinResponse = this.signinService.signin(request, reply, user);
return {
signinResponse: signinResponse,
};
}
}
Loading
Loading