summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints/i/2fa
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api/endpoints/i/2fa
parentupdate deps (diff)
downloadmisskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api/endpoints/i/2fa')
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/done.ts41
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts150
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/password-less.ts21
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register-key.ts59
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts32
7 files changed, 402 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts
new file mode 100644
index 0000000000..2bd2128cce
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts
@@ -0,0 +1,41 @@
+import $ from 'cafy';
+import * as speakeasy from 'speakeasy';
+import define from '../../../define';
+import { UserProfiles } from '@/models/index';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ token: {
+ validator: $.str
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const token = ps.token.replace(/\s/g, '');
+
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ if (profile.twoFactorTempSecret == null) {
+ throw new Error('二段階認証の設定が開始されていません');
+ }
+
+ const verified = (speakeasy as any).totp.verify({
+ secret: profile.twoFactorTempSecret,
+ encoding: 'base32',
+ token: token
+ });
+
+ if (!verified) {
+ throw new Error('not verified');
+ }
+
+ await UserProfiles.update(user.id, {
+ twoFactorSecret: profile.twoFactorTempSecret,
+ twoFactorEnabled: true
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
new file mode 100644
index 0000000000..b4d3af235a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
@@ -0,0 +1,150 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import { promisify } from 'util';
+import * as cbor from 'cbor';
+import define from '../../../define';
+import {
+ UserProfiles,
+ UserSecurityKeys,
+ AttestationChallenges,
+ Users
+} from '@/models/index';
+import config from '@/config/index';
+import { procedures, hash } from '../../../2fa';
+import { publishMainStream } from '@/services/stream';
+
+const cborDecodeFirst = promisify(cbor.decodeFirst) as any;
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ clientDataJSON: {
+ validator: $.str
+ },
+ attestationObject: {
+ validator: $.str
+ },
+ password: {
+ validator: $.str
+ },
+ challengeId: {
+ validator: $.str
+ },
+ name: {
+ validator: $.str
+ }
+ }
+};
+
+const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8'));
+
+export default define(meta, async (ps, user) => {
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ // Compare password
+ const same = await bcrypt.compare(ps.password, profile.password!);
+
+ if (!same) {
+ throw new Error('incorrect password');
+ }
+
+ if (!profile.twoFactorEnabled) {
+ throw new Error('2fa not enabled');
+ }
+
+ const clientData = JSON.parse(ps.clientDataJSON);
+
+ if (clientData.type != 'webauthn.create') {
+ throw new Error('not a creation attestation');
+ }
+ if (clientData.origin != config.scheme + '://' + config.host) {
+ throw new Error('origin mismatch');
+ }
+
+ const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
+
+ const attestation = await cborDecodeFirst(ps.attestationObject);
+
+ const rpIdHash = attestation.authData.slice(0, 32);
+ if (!rpIdHashReal.equals(rpIdHash)) {
+ throw new Error('rpIdHash mismatch');
+ }
+
+ const flags = attestation.authData[32];
+
+ // tslint:disable-next-line:no-bitwise
+ if (!(flags & 1)) {
+ throw new Error('user not present');
+ }
+
+ const authData = Buffer.from(attestation.authData);
+ const credentialIdLength = authData.readUInt16BE(53);
+ const credentialId = authData.slice(55, 55 + credentialIdLength);
+ const publicKeyData = authData.slice(55 + credentialIdLength);
+ const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
+ if (publicKey.get(3) != -7) {
+ throw new Error('alg mismatch');
+ }
+
+ if (!(procedures as any)[attestation.fmt]) {
+ throw new Error('unsupported fmt');
+ }
+
+ const verificationData = (procedures as any)[attestation.fmt].verify({
+ attStmt: attestation.attStmt,
+ authenticatorData: authData,
+ clientDataHash: clientDataJSONHash,
+ credentialId,
+ publicKey,
+ rpIdHash
+ });
+ if (!verificationData.valid) throw new Error('signature invalid');
+
+ const attestationChallenge = await AttestationChallenges.findOne({
+ userId: user.id,
+ id: ps.challengeId,
+ registrationChallenge: true,
+ challenge: hash(clientData.challenge).toString('hex')
+ });
+
+ if (!attestationChallenge) {
+ throw new Error('non-existent challenge');
+ }
+
+ await AttestationChallenges.delete({
+ userId: user.id,
+ id: ps.challengeId
+ });
+
+ // Expired challenge (> 5min old)
+ if (
+ new Date().getTime() - attestationChallenge.createdAt.getTime() >=
+ 5 * 60 * 1000
+ ) {
+ throw new Error('expired challenge');
+ }
+
+ const credentialIdString = credentialId.toString('hex');
+
+ await UserSecurityKeys.save({
+ userId: user.id,
+ id: credentialIdString,
+ lastUsed: new Date(),
+ name: ps.name,
+ publicKey: verificationData.publicKey.toString('hex')
+ });
+
+ // Publish meUpdated event
+ publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
+ detail: true,
+ includeSecrets: true
+ }));
+
+ return {
+ id: credentialIdString,
+ name: ps.name
+ };
+});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts
new file mode 100644
index 0000000000..064828b638
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts
@@ -0,0 +1,21 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { UserProfiles } from '@/models/index';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ value: {
+ validator: $.boolean
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ await UserProfiles.update(user.id, {
+ usePasswordLessLogin: ps.value
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
new file mode 100644
index 0000000000..1b385a10ee
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
@@ -0,0 +1,59 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import define from '../../../define';
+import { UserProfiles, AttestationChallenges } from '@/models/index';
+import { promisify } from 'util';
+import * as crypto from 'crypto';
+import { genId } from '@/misc/gen-id';
+import { hash } from '../../../2fa';
+
+const randomBytes = promisify(crypto.randomBytes);
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ password: {
+ validator: $.str
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ // Compare password
+ const same = await bcrypt.compare(ps.password, profile.password!);
+
+ if (!same) {
+ throw new Error('incorrect password');
+ }
+
+ if (!profile.twoFactorEnabled) {
+ throw new Error('2fa not enabled');
+ }
+
+ // 32 byte challenge
+ const entropy = await randomBytes(32);
+ const challenge = entropy.toString('base64')
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+
+ const challengeId = genId();
+
+ await AttestationChallenges.save({
+ userId: user.id,
+ id: challengeId,
+ challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
+ createdAt: new Date(),
+ registrationChallenge: true
+ });
+
+ return {
+ challengeId,
+ challenge
+ };
+});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
new file mode 100644
index 0000000000..b03b98188a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
@@ -0,0 +1,54 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import * as speakeasy from 'speakeasy';
+import * as QRCode from 'qrcode';
+import config from '@/config/index';
+import define from '../../../define';
+import { UserProfiles } from '@/models/index';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ password: {
+ validator: $.str
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ // Compare password
+ const same = await bcrypt.compare(ps.password, profile.password!);
+
+ if (!same) {
+ throw new Error('incorrect password');
+ }
+
+ // Generate user's secret key
+ const secret = speakeasy.generateSecret({
+ length: 32
+ });
+
+ await UserProfiles.update(user.id, {
+ twoFactorTempSecret: secret.base32
+ });
+
+ // Get the data URL of the authenticator URL
+ const dataUrl = await QRCode.toDataURL(speakeasy.otpauthURL({
+ secret: secret.base32,
+ encoding: 'base32',
+ label: user.username,
+ issuer: config.host
+ }));
+
+ return {
+ qr: dataUrl,
+ secret: secret.base32,
+ label: user.username,
+ issuer: config.host
+ };
+});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
new file mode 100644
index 0000000000..dea56301ab
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import define from '../../../define';
+import { UserProfiles, UserSecurityKeys, Users } from '@/models/index';
+import { publishMainStream } from '@/services/stream';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ password: {
+ validator: $.str
+ },
+ credentialId: {
+ validator: $.str
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ // Compare password
+ const same = await bcrypt.compare(ps.password, profile.password!);
+
+ if (!same) {
+ throw new Error('incorrect password');
+ }
+
+ // Make sure we only delete the user's own creds
+ await UserSecurityKeys.delete({
+ userId: user.id,
+ id: ps.credentialId
+ });
+
+ // Publish meUpdated event
+ publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
+ detail: true,
+ includeSecrets: true
+ }));
+
+ return {};
+});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
new file mode 100644
index 0000000000..af53033daa
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
@@ -0,0 +1,32 @@
+import $ from 'cafy';
+import * as bcrypt from 'bcryptjs';
+import define from '../../../define';
+import { UserProfiles } from '@/models/index';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ password: {
+ validator: $.str
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ // Compare password
+ const same = await bcrypt.compare(ps.password, profile.password!);
+
+ if (!same) {
+ throw new Error('incorrect password');
+ }
+
+ await UserProfiles.update(user.id, {
+ twoFactorSecret: null,
+ twoFactorEnabled: false
+ });
+});