diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api/endpoints/i/2fa | |
| parent | update deps (diff) | |
| download | misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api/endpoints/i/2fa')
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 + }); +}); |