mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-27 21:10:20 +01:00
Implement Webauthn 🎉 (#5088)
* Implement Webauthn 🎉
* Share hexifyAB
* Move hr inside template and add AttestationChallenges janitor daemon
* Apply suggestions from code review
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Add newline at the end of file
* Fix stray newline in promise chain
* Ignore var in try{}catch(){} block
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Add missing comma
* Add missing semicolon
* Support more attestation formats
* add support for more key types and linter pass
* Refactor
* Refactor
* credentialId --> id
* Fix
* Improve readability
* Add indexes
* fixes for credentialId->id
* Avoid changing store state
* Fix syntax error and code style
* Remove unused import
* Refactor of getkey API
* Create 1561706992953-webauthn.ts
* Update ja-JP.yml
* Add type annotations
* Fix code style
* Specify depedency version
* Fix code style
* Fix janitor daemon and login requesting 2FA regardless of status
This commit is contained in:
parent
f17e229c1e
commit
fd94b817ab
21 changed files with 1376 additions and 64 deletions
|
@ -601,6 +601,8 @@ common/views/components/signin.vue:
|
||||||
signin-with-github: "Sign in with GitHub"
|
signin-with-github: "Sign in with GitHub"
|
||||||
signin-with-discord: "Sign in with Discord"
|
signin-with-discord: "Sign in with Discord"
|
||||||
login-failed: "Logging in has failed. Make sure you have entered the correct username and password."
|
login-failed: "Logging in has failed. Make sure you have entered the correct username and password."
|
||||||
|
tap-key: "Activate your security key by tapping or clicking it to login"
|
||||||
|
enter-2fa-code: "Enter your 2FA code below"
|
||||||
common/views/components/signup.vue:
|
common/views/components/signup.vue:
|
||||||
invitation-code: "Invitation code"
|
invitation-code: "Invitation code"
|
||||||
invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>."
|
invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>."
|
||||||
|
@ -984,7 +986,7 @@ desktop/views/components/settings.2fa.vue:
|
||||||
url: "https://www.google.com/landing/2step/"
|
url: "https://www.google.com/landing/2step/"
|
||||||
caution: "If you lose access to your registered device, you won't be able to connect to Misskey anymore!"
|
caution: "If you lose access to your registered device, you won't be able to connect to Misskey anymore!"
|
||||||
register: "Register a device"
|
register: "Register a device"
|
||||||
already-registered: "This device is already registered"
|
already-registered: "Your account is currently registered to an authenticator application"
|
||||||
unregister: "Unregister"
|
unregister: "Unregister"
|
||||||
unregistered: "Two-factor authentication has been disabled."
|
unregistered: "Two-factor authentication has been disabled."
|
||||||
enter-password: "Enter the password"
|
enter-password: "Enter the password"
|
||||||
|
@ -997,6 +999,15 @@ desktop/views/components/settings.2fa.vue:
|
||||||
success: "Settings saved!"
|
success: "Settings saved!"
|
||||||
failed: "Failed to setup. Please ensure that the token is correct."
|
failed: "Failed to setup. Please ensure that the token is correct."
|
||||||
info: "From the next time you sign in to Misskey, the token displayed on your device will be necessary too, as well as the password."
|
info: "From the next time you sign in to Misskey, the token displayed on your device will be necessary too, as well as the password."
|
||||||
|
totp-header: "Authenticator App"
|
||||||
|
security-key-header: "Security Keys"
|
||||||
|
security-key: "You can use a hardware security key supporting FIDO2 to log into your account for enhanced security. When you sign-in, you'll need a registered security key or your authenticator app."
|
||||||
|
last-used: "Last used:"
|
||||||
|
activate-key: "Please activate your security key by tapping or clicking it"
|
||||||
|
security-key-name: "Key Name"
|
||||||
|
register-security-key: "Finish Key Registration"
|
||||||
|
something-went-wrong: "Oops! Something went wrong while trying to register your key:"
|
||||||
|
key-unregistered: "Key Removed"
|
||||||
common/views/components/media-image.vue:
|
common/views/components/media-image.vue:
|
||||||
sensitive: "NSFW"
|
sensitive: "NSFW"
|
||||||
click-to-show: "Click to show"
|
click-to-show: "Click to show"
|
||||||
|
|
|
@ -646,6 +646,8 @@ common/views/components/signin.vue:
|
||||||
signin-with-github: "GitHubでログイン"
|
signin-with-github: "GitHubでログイン"
|
||||||
signin-with-discord: "Discordでログイン"
|
signin-with-discord: "Discordでログイン"
|
||||||
login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
|
login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
|
||||||
|
tap-key: "セキュリティキーをクリックしてログイン"
|
||||||
|
enter-2fa-code: "認証コードを入力してください"
|
||||||
|
|
||||||
common/views/components/signup.vue:
|
common/views/components/signup.vue:
|
||||||
invitation-code: "招待コード"
|
invitation-code: "招待コード"
|
||||||
|
@ -1100,6 +1102,15 @@ desktop/views/components/settings.2fa.vue:
|
||||||
success: "設定が完了しました!"
|
success: "設定が完了しました!"
|
||||||
failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
|
failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
|
||||||
info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
|
info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
|
||||||
|
totp-header: "認証アプリ"
|
||||||
|
security-key-header: "セキュリティキー"
|
||||||
|
security-key: "セキュリティを強化するために、FIDO2をサポートするハードウェアセキュリティキーを使用してアカウントにログインできます。 サインインの際は、登録されたセキュリティキーまたは認証アプリが必要になります。"
|
||||||
|
last-used: "最後の使用:"
|
||||||
|
activate-key: "クリックしてセキュリティキーをアクティベートしてください"
|
||||||
|
security-key-name: "キー名"
|
||||||
|
register-security-key: "キーの登録を完了"
|
||||||
|
something-went-wrong: "わー! キーを登録する際に問題が発生しました:"
|
||||||
|
key-unregistered: "キーが削除されました"
|
||||||
|
|
||||||
common/views/components/media-image.vue:
|
common/views/components/media-image.vue:
|
||||||
sensitive: "閲覧注意"
|
sensitive: "閲覧注意"
|
||||||
|
|
29
migration/1561706992953-webauthn.ts
Normal file
29
migration/1561706992953-webauthn.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||||
|
|
||||||
|
export class webauthn1561706992953 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `);
|
||||||
|
await queryRunner.query(`CREATE TABLE "user_security_key" ("id" character varying NOT NULL, "userId" character varying(32) NOT NULL, "publicKey" character varying NOT NULL, "lastUsed" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(30) NOT NULL, CONSTRAINT "PK_3e508571121ab39c5f85d10c166" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44" ON "user_security_key" ("userId") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7" ON "user_security_key" ("publicKey") `);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_profile" ADD "securityKeysAvailable" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_security_key" ADD CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_security_key" DROP CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "securityKeysAvailable"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_security_key"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "attestation_challenge"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -39,6 +39,7 @@
|
||||||
"@koa/cors": "3.0.0",
|
"@koa/cors": "3.0.0",
|
||||||
"@types/bcryptjs": "2.4.2",
|
"@types/bcryptjs": "2.4.2",
|
||||||
"@types/bull": "3.5.15",
|
"@types/bull": "3.5.15",
|
||||||
|
"@types/cbor": "2.0.0",
|
||||||
"@types/dateformat": "3.0.0",
|
"@types/dateformat": "3.0.0",
|
||||||
"@types/deep-equal": "1.0.1",
|
"@types/deep-equal": "1.0.1",
|
||||||
"@types/double-ended-queue": "2.1.1",
|
"@types/double-ended-queue": "2.1.1",
|
||||||
|
@ -104,9 +105,11 @@
|
||||||
"autosize": "4.0.2",
|
"autosize": "4.0.2",
|
||||||
"autwh": "0.1.0",
|
"autwh": "0.1.0",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
|
"bootstrap": "4.3.1",
|
||||||
"bootstrap-vue": "2.0.0-rc.13",
|
"bootstrap-vue": "2.0.0-rc.13",
|
||||||
"bull": "3.10.0",
|
"bull": "3.10.0",
|
||||||
"cafy": "15.1.1",
|
"cafy": "15.1.1",
|
||||||
|
"cbor": "4.1.5",
|
||||||
"chai": "4.2.0",
|
"chai": "4.2.0",
|
||||||
"chalk": "2.4.2",
|
"chalk": "2.4.2",
|
||||||
"cli-highlight": "2.1.1",
|
"cli-highlight": "2.1.1",
|
||||||
|
@ -148,6 +151,7 @@
|
||||||
"jsdom": "15.1.1",
|
"jsdom": "15.1.1",
|
||||||
"json5": "2.1.0",
|
"json5": "2.1.0",
|
||||||
"json5-loader": "3.0.0",
|
"json5-loader": "3.0.0",
|
||||||
|
"jsrsasign": "8.0.12",
|
||||||
"katex": "0.10.2",
|
"katex": "0.10.2",
|
||||||
"koa": "2.7.0",
|
"koa": "2.7.0",
|
||||||
"koa-bodyparser": "4.2.1",
|
"koa-bodyparser": "4.2.1",
|
||||||
|
|
|
@ -79,6 +79,7 @@ export async function masterMain() {
|
||||||
require('../daemons/server-stats').default();
|
require('../daemons/server-stats').default();
|
||||||
require('../daemons/notes-stats').default();
|
require('../daemons/notes-stats').default();
|
||||||
require('../daemons/queue-stats').default();
|
require('../daemons/queue-stats').default();
|
||||||
|
require('../daemons/janitor').default();
|
||||||
}
|
}
|
||||||
|
|
||||||
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
|
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
|
||||||
|
|
5
src/client/app/common/scripts/2fa.ts
Normal file
5
src/client/app/common/scripts/2fa.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export function hexifyAB(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map(item => item.toString(16).padStart(2, 0))
|
||||||
|
.join('');
|
||||||
|
}
|
|
@ -1,11 +1,54 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="2fa">
|
<div class="2fa totp-section">
|
||||||
<p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p>
|
<p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p>
|
||||||
<ui-info warn>{{ $t('caution') }}</ui-info>
|
<ui-info warn>{{ $t('caution') }}</ui-info>
|
||||||
<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p>
|
<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p>
|
||||||
<template v-if="$store.state.i.twoFactorEnabled">
|
<template v-if="$store.state.i.twoFactorEnabled">
|
||||||
|
<h2 class="heading">{{ $t('totp-header') }}</h2>
|
||||||
<p>{{ $t('already-registered') }}</p>
|
<p>{{ $t('already-registered') }}</p>
|
||||||
<ui-button @click="unregister">{{ $t('unregister') }}</ui-button>
|
<ui-button @click="unregister">{{ $t('unregister') }}</ui-button>
|
||||||
|
|
||||||
|
<template v-if="supportsCredentials">
|
||||||
|
<hr class="totp-method-sep">
|
||||||
|
|
||||||
|
<h2 class="heading">{{ $t('security-key-header') }}</h2>
|
||||||
|
<p>{{ $t('security-key') }}</p>
|
||||||
|
<div class="key-list">
|
||||||
|
<div class="key" v-for="key in $store.state.i.securityKeysList">
|
||||||
|
<h3>
|
||||||
|
{{ key.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="last-used">
|
||||||
|
{{ $t('last-used') }}
|
||||||
|
<mk-time :time="key.lastUsed"/>
|
||||||
|
</div>
|
||||||
|
<ui-button @click="unregisterKey(key)">
|
||||||
|
{{ $t('unregister') }}
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info>
|
||||||
|
<ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button>
|
||||||
|
|
||||||
|
<ol v-if="registration && !registration.error">
|
||||||
|
<li v-if="registration.stage >= 0">
|
||||||
|
{{ $t('activate-key') }}
|
||||||
|
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
|
||||||
|
</li>
|
||||||
|
<li v-if="registration.stage >= 1">
|
||||||
|
<ui-form :disabled="registration.stage != 1 || registration.saving">
|
||||||
|
<ui-input v-model="keyName" :max="30">
|
||||||
|
<span>{{ $t('security-key-name') }}</span>
|
||||||
|
</ui-input>
|
||||||
|
<ui-button @click="registerKey" :disabled="this.keyName.length == 0">
|
||||||
|
{{ $t('register-security-key') }}
|
||||||
|
</ui-button>
|
||||||
|
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
|
||||||
|
</ui-form>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="data && !$store.state.i.twoFactorEnabled">
|
<div v-if="data && !$store.state.i.twoFactorEnabled">
|
||||||
<ol>
|
<ol>
|
||||||
|
@ -24,12 +67,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import i18n from '../../../../i18n';
|
import i18n from '../../../../i18n';
|
||||||
|
import { hostname } from '../../../../config';
|
||||||
|
import { hexifyAB } from '../../../scripts/2fa';
|
||||||
|
|
||||||
|
function stringifyAB(buffer) {
|
||||||
|
return String.fromCharCode.apply(null, new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
i18n: i18n('desktop/views/components/settings.2fa.vue'),
|
i18n: i18n('desktop/views/components/settings.2fa.vue'),
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
|
supportsCredentials: !!navigator.credentials,
|
||||||
|
registration: null,
|
||||||
|
keyName: '',
|
||||||
token: null
|
token: null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -76,7 +128,116 @@ export default Vue.extend({
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
this.$notify(this.$t('failed'));
|
this.$notify(this.$t('failed'));
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
registerKey() {
|
||||||
|
this.registration.saving = true;
|
||||||
|
this.$root.api('i/2fa/key-done', {
|
||||||
|
password: this.registration.password,
|
||||||
|
name: this.keyName,
|
||||||
|
challengeId: this.registration.challengeId,
|
||||||
|
// we convert each 16 bits to a string to serialise
|
||||||
|
clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
|
||||||
|
attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
|
||||||
|
}).then(key => {
|
||||||
|
this.registration = null;
|
||||||
|
key.lastUsed = new Date();
|
||||||
|
this.$notify(this.$t('success'));
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
unregisterKey(key) {
|
||||||
|
this.$root.dialog({
|
||||||
|
title: this.$t('enter-password'),
|
||||||
|
input: {
|
||||||
|
type: 'password'
|
||||||
|
}
|
||||||
|
}).then(({ canceled, result: password }) => {
|
||||||
|
if (canceled) return;
|
||||||
|
return this.$root.api('i/2fa/remove-key', {
|
||||||
|
password,
|
||||||
|
credentialId: key.id
|
||||||
|
}).then(() => {
|
||||||
|
this.$notify(this.$t('key-unregistered'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addSecurityKey() {
|
||||||
|
this.$root.dialog({
|
||||||
|
title: this.$t('enter-password'),
|
||||||
|
input: {
|
||||||
|
type: 'password'
|
||||||
|
}
|
||||||
|
}).then(({ canceled, result: password }) => {
|
||||||
|
if (canceled) return;
|
||||||
|
this.$root.api('i/2fa/register-key', {
|
||||||
|
password
|
||||||
|
}).then(registration => {
|
||||||
|
this.registration = {
|
||||||
|
password,
|
||||||
|
challengeId: registration.challengeId,
|
||||||
|
stage: 0,
|
||||||
|
publicKeyOptions: {
|
||||||
|
challenge: Buffer.from(
|
||||||
|
registration.challenge
|
||||||
|
.replace(/\-/g, "+")
|
||||||
|
.replace(/_/g, "/"),
|
||||||
|
'base64'
|
||||||
|
),
|
||||||
|
rp: {
|
||||||
|
id: hostname,
|
||||||
|
name: 'Misskey'
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
|
||||||
|
name: this.$store.state.i.username,
|
||||||
|
displayName: this.$store.state.i.name,
|
||||||
|
},
|
||||||
|
pubKeyCredParams: [{alg: -7, type: 'public-key'}],
|
||||||
|
timeout: 60000,
|
||||||
|
attestation: 'direct'
|
||||||
|
},
|
||||||
|
saving: true
|
||||||
|
};
|
||||||
|
return navigator.credentials.create({
|
||||||
|
publicKey: this.registration.publicKeyOptions
|
||||||
|
});
|
||||||
|
}).then(credential => {
|
||||||
|
this.registration.credential = credential;
|
||||||
|
this.registration.saving = false;
|
||||||
|
this.registration.stage = 1;
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('Error while registering?', err);
|
||||||
|
this.registration.error = err.message;
|
||||||
|
this.registration.stage = -1;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus" scoped>
|
||||||
|
.totp-section
|
||||||
|
.totp-method-sep
|
||||||
|
margin 1.5em 0 1em
|
||||||
|
border none
|
||||||
|
border-top solid var(--lineWidth) var(--faceDivider)
|
||||||
|
|
||||||
|
h2.heading
|
||||||
|
margin 0
|
||||||
|
|
||||||
|
.key
|
||||||
|
padding 1em
|
||||||
|
margin 0.5em 0
|
||||||
|
background #161616
|
||||||
|
border-radius 6px
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top 0
|
||||||
|
margin-bottom .3em
|
||||||
|
|
||||||
|
.last-used
|
||||||
|
margin-bottom .5em
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,23 +1,40 @@
|
||||||
<template>
|
<template>
|
||||||
<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit">
|
<form class="mk-signin" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
|
||||||
<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
|
<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
|
||||||
<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
|
<div class="normal-signin" v-if="!totpLogin">
|
||||||
<span>{{ $t('username') }}</span>
|
<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
|
||||||
<template #prefix>@</template>
|
<span>{{ $t('username') }}</span>
|
||||||
<template #suffix>@{{ host }}</template>
|
<template #prefix>@</template>
|
||||||
</ui-input>
|
<template #suffix>@{{ host }}</template>
|
||||||
<ui-input v-model="password" type="password" :with-password-toggle="true" required>
|
</ui-input>
|
||||||
<span>{{ $t('password') }}</span>
|
<ui-input v-model="password" type="password" :with-password-toggle="true" required>
|
||||||
<template #prefix><fa icon="lock"/></template>
|
<span>{{ $t('password') }}</span>
|
||||||
</ui-input>
|
<template #prefix><fa icon="lock"/></template>
|
||||||
<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
|
</ui-input>
|
||||||
<span>{{ $t('@.2fa') }}</span>
|
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
|
||||||
<template #prefix><fa icon="gavel"/></template>
|
<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
|
||||||
</ui-input>
|
<p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
|
||||||
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
|
<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
|
||||||
<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
|
</div>
|
||||||
<p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
|
<div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
|
||||||
<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
|
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
||||||
|
<p>{{ $t('tap-key') }}</p>
|
||||||
|
<ui-button @click="queryKey" v-if="!queryingKey">
|
||||||
|
{{ $t('@.error.retry') }}
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
<div class="or-hr" v-if="user && user.securityKeys">
|
||||||
|
<p class="or-msg">{{ $t('or') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="twofa-group totp-group">
|
||||||
|
<p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p>
|
||||||
|
<ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
|
||||||
|
<span>{{ $t('@.2fa') }}</span>
|
||||||
|
<template #prefix><fa icon="gavel"/></template>
|
||||||
|
</ui-input>
|
||||||
|
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -26,6 +43,7 @@ import Vue from 'vue';
|
||||||
import i18n from '../../../i18n';
|
import i18n from '../../../i18n';
|
||||||
import { apiUrl, host } from '../../../config';
|
import { apiUrl, host } from '../../../config';
|
||||||
import { toUnicode } from 'punycode';
|
import { toUnicode } from 'punycode';
|
||||||
|
import { hexifyAB } from '../../scripts/2fa';
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
i18n: i18n('common/views/components/signin.vue'),
|
i18n: i18n('common/views/components/signin.vue'),
|
||||||
|
@ -47,7 +65,11 @@ export default Vue.extend({
|
||||||
token: '',
|
token: '',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
host: toUnicode(host),
|
host: toUnicode(host),
|
||||||
meta: null
|
meta: null,
|
||||||
|
totpLogin: false,
|
||||||
|
credential: null,
|
||||||
|
challengeData: null,
|
||||||
|
queryingKey: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -68,23 +90,87 @@ export default Vue.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit() {
|
queryKey() {
|
||||||
this.signing = true;
|
this.queryingKey = true;
|
||||||
|
return navigator.credentials.get({
|
||||||
this.$root.api('signin', {
|
publicKey: {
|
||||||
username: this.username,
|
challenge: Buffer.from(
|
||||||
password: this.password,
|
this.challengeData.challenge
|
||||||
token: this.user && this.user.twoFactorEnabled ? this.token : undefined
|
.replace(/\-/g, '+')
|
||||||
|
.replace(/_/g, '/'),
|
||||||
|
'base64'
|
||||||
|
),
|
||||||
|
allowCredentials: this.challengeData.securityKeys.map(key => ({
|
||||||
|
id: Buffer.from(key.id, 'hex'),
|
||||||
|
type: 'public-key',
|
||||||
|
transports: ['usb', 'ble', 'nfc']
|
||||||
|
})),
|
||||||
|
timeout: 60 * 1000
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
this.queryingKey = false;
|
||||||
|
console.warn(err);
|
||||||
|
return Promise.reject(null);
|
||||||
|
}).then(credential => {
|
||||||
|
this.queryingKey = false;
|
||||||
|
this.signing = true;
|
||||||
|
return this.$root.api('signin', {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
signature: hexifyAB(credential.response.signature),
|
||||||
|
authenticatorData: hexifyAB(credential.response.authenticatorData),
|
||||||
|
clientDataJSON: hexifyAB(credential.response.clientDataJSON),
|
||||||
|
credentialId: credential.id,
|
||||||
|
challengeId: this.challengeData.challengeId
|
||||||
|
});
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
localStorage.setItem('i', res.i);
|
localStorage.setItem('i', res.i);
|
||||||
location.reload();
|
location.reload();
|
||||||
}).catch(() => {
|
}).catch(err => {
|
||||||
|
if(err === null) return;
|
||||||
|
console.error(err);
|
||||||
this.$root.dialog({
|
this.$root.dialog({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: this.$t('login-failed')
|
text: this.$t('login-failed')
|
||||||
});
|
});
|
||||||
this.signing = false;
|
this.signing = false;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
this.signing = true;
|
||||||
|
|
||||||
|
if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
|
||||||
|
if (window.PublicKeyCredential && this.user.securityKeys) {
|
||||||
|
this.$root.api('i/2fa/getkeys', {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password
|
||||||
|
}).then(res => {
|
||||||
|
this.totpLogin = true;
|
||||||
|
this.signing = false;
|
||||||
|
this.challengeData = res;
|
||||||
|
return this.queryKey();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.totpLogin = true;
|
||||||
|
this.signing = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.$root.api('signin', {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
token: this.user && this.user.twoFactorEnabled ? this.token : undefined
|
||||||
|
}).then(res => {
|
||||||
|
localStorage.setItem('i', res.i);
|
||||||
|
location.reload();
|
||||||
|
}).catch(() => {
|
||||||
|
this.$root.dialog({
|
||||||
|
type: 'error',
|
||||||
|
text: this.$t('login-failed')
|
||||||
|
});
|
||||||
|
this.signing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -94,6 +180,48 @@ export default Vue.extend({
|
||||||
.mk-signin
|
.mk-signin
|
||||||
color #555
|
color #555
|
||||||
|
|
||||||
|
.or-hr,
|
||||||
|
.or-hr .or-msg,
|
||||||
|
.twofa-group,
|
||||||
|
.twofa-group p
|
||||||
|
color var(--text)
|
||||||
|
|
||||||
|
.tap-group > button
|
||||||
|
margin-bottom 1em
|
||||||
|
|
||||||
|
.securityKeys .or-hr
|
||||||
|
&
|
||||||
|
position relative
|
||||||
|
|
||||||
|
.or-msg
|
||||||
|
&:before
|
||||||
|
right 100%
|
||||||
|
margin-right 0.125em
|
||||||
|
|
||||||
|
&:after
|
||||||
|
left 100%
|
||||||
|
margin-left 0.125em
|
||||||
|
|
||||||
|
&:before, &:after
|
||||||
|
content ""
|
||||||
|
position absolute
|
||||||
|
top 50%
|
||||||
|
width 100%
|
||||||
|
height 2px
|
||||||
|
background #555
|
||||||
|
|
||||||
|
&
|
||||||
|
position relative
|
||||||
|
margin auto
|
||||||
|
left 0
|
||||||
|
right 0
|
||||||
|
top 0
|
||||||
|
bottom 0
|
||||||
|
font-size 1.5em
|
||||||
|
height 1.5em
|
||||||
|
width 3em
|
||||||
|
text-align center
|
||||||
|
|
||||||
&.signing
|
&.signing
|
||||||
&, *
|
&, *
|
||||||
cursor wait !important
|
cursor wait !important
|
||||||
|
|
18
src/daemons/janitor.ts
Normal file
18
src/daemons/janitor.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
const interval = 30 * 60 * 1000;
|
||||||
|
import { AttestationChallenges } from '../models';
|
||||||
|
import { LessThan } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up database occasionally
|
||||||
|
*/
|
||||||
|
export default function() {
|
||||||
|
async function tick() {
|
||||||
|
await AttestationChallenges.delete({
|
||||||
|
createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
setInterval(tick, interval);
|
||||||
|
}
|
|
@ -43,6 +43,8 @@ import { Poll } from '../models/entities/poll';
|
||||||
import { UserKeypair } from '../models/entities/user-keypair';
|
import { UserKeypair } from '../models/entities/user-keypair';
|
||||||
import { UserPublickey } from '../models/entities/user-publickey';
|
import { UserPublickey } from '../models/entities/user-publickey';
|
||||||
import { UserProfile } from '../models/entities/user-profile';
|
import { UserProfile } from '../models/entities/user-profile';
|
||||||
|
import { UserSecurityKey } from '../models/entities/user-security-key';
|
||||||
|
import { AttestationChallenge } from '../models/entities/attestation-challenge';
|
||||||
import { Page } from '../models/entities/page';
|
import { Page } from '../models/entities/page';
|
||||||
import { PageLike } from '../models/entities/page-like';
|
import { PageLike } from '../models/entities/page-like';
|
||||||
|
|
||||||
|
@ -96,6 +98,8 @@ export const entities = [
|
||||||
UserGroupJoining,
|
UserGroupJoining,
|
||||||
UserGroupInvite,
|
UserGroupInvite,
|
||||||
UserNotePining,
|
UserNotePining,
|
||||||
|
UserSecurityKey,
|
||||||
|
AttestationChallenge,
|
||||||
Following,
|
Following,
|
||||||
FollowRequest,
|
FollowRequest,
|
||||||
Muting,
|
Muting,
|
||||||
|
@ -146,7 +150,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
|
||||||
options: {
|
options: {
|
||||||
host: config.redis.host,
|
host: config.redis.host,
|
||||||
port: config.redis.port,
|
port: config.redis.port,
|
||||||
options:{
|
options: {
|
||||||
password: config.redis.pass,
|
password: config.redis.pass,
|
||||||
prefix: config.redis.prefix,
|
prefix: config.redis.prefix,
|
||||||
db: config.redis.db || 0
|
db: config.redis.db || 0
|
||||||
|
|
46
src/models/entities/attestation-challenge.ts
Normal file
46
src/models/entities/attestation-challenge.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
|
||||||
|
import { User } from './user';
|
||||||
|
import { id } from '../id';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class AttestationChallenge {
|
||||||
|
@PrimaryColumn(id())
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@PrimaryColumn(id())
|
||||||
|
public userId: User['id'];
|
||||||
|
|
||||||
|
@ManyToOne(type => User, {
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public user: User | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 64,
|
||||||
|
comment: 'Hex-encoded sha256 hash of the challenge.'
|
||||||
|
})
|
||||||
|
public challenge: string;
|
||||||
|
|
||||||
|
@Column('timestamp with time zone', {
|
||||||
|
comment: 'The date challenge was created for expiry purposes.'
|
||||||
|
})
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@Column('boolean', {
|
||||||
|
comment:
|
||||||
|
'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
public registrationChallenge: boolean;
|
||||||
|
|
||||||
|
constructor(data: Partial<AttestationChallenge>) {
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
(this as any)[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -76,6 +76,11 @@ export class UserProfile {
|
||||||
})
|
})
|
||||||
public twoFactorEnabled: boolean;
|
public twoFactorEnabled: boolean;
|
||||||
|
|
||||||
|
@Column('boolean', {
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
public securityKeysAvailable: boolean;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 128, nullable: true,
|
length: 128, nullable: true,
|
||||||
comment: 'The password hash of the User. It will be null if the origin of the user is local.'
|
comment: 'The password hash of the User. It will be null if the origin of the user is local.'
|
||||||
|
|
48
src/models/entities/user-security-key.ts
Normal file
48
src/models/entities/user-security-key.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
|
||||||
|
import { User } from './user';
|
||||||
|
import { id } from '../id';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class UserSecurityKey {
|
||||||
|
@PrimaryColumn('varchar', {
|
||||||
|
comment: 'Variable-length id given to navigator.credentials.get()'
|
||||||
|
})
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column(id())
|
||||||
|
public userId: User['id'];
|
||||||
|
|
||||||
|
@ManyToOne(type => User, {
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public user: User | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('varchar', {
|
||||||
|
comment:
|
||||||
|
'Variable-length public key used to verify attestations (hex-encoded).'
|
||||||
|
})
|
||||||
|
public publicKey: string;
|
||||||
|
|
||||||
|
@Column('timestamp with time zone', {
|
||||||
|
comment:
|
||||||
|
'The date of the last time the UserSecurityKey was successfully validated.'
|
||||||
|
})
|
||||||
|
public lastUsed: Date;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
comment: 'User-defined name for this key',
|
||||||
|
length: 30
|
||||||
|
})
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
constructor(data: Partial<UserSecurityKey>) {
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
(this as any)[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,6 +37,8 @@ import { FollowingRepository } from './repositories/following';
|
||||||
import { AbuseUserReportRepository } from './repositories/abuse-user-report';
|
import { AbuseUserReportRepository } from './repositories/abuse-user-report';
|
||||||
import { AuthSessionRepository } from './repositories/auth-session';
|
import { AuthSessionRepository } from './repositories/auth-session';
|
||||||
import { UserProfile } from './entities/user-profile';
|
import { UserProfile } from './entities/user-profile';
|
||||||
|
import { AttestationChallenge } from './entities/attestation-challenge';
|
||||||
|
import { UserSecurityKey } from './entities/user-security-key';
|
||||||
import { HashtagRepository } from './repositories/hashtag';
|
import { HashtagRepository } from './repositories/hashtag';
|
||||||
import { PageRepository } from './repositories/page';
|
import { PageRepository } from './repositories/page';
|
||||||
import { PageLikeRepository } from './repositories/page-like';
|
import { PageLikeRepository } from './repositories/page-like';
|
||||||
|
@ -52,6 +54,8 @@ export const PollVotes = getRepository(PollVote);
|
||||||
export const Users = getCustomRepository(UserRepository);
|
export const Users = getCustomRepository(UserRepository);
|
||||||
export const UserProfiles = getRepository(UserProfile);
|
export const UserProfiles = getRepository(UserProfile);
|
||||||
export const UserKeypairs = getRepository(UserKeypair);
|
export const UserKeypairs = getRepository(UserKeypair);
|
||||||
|
export const AttestationChallenges = getRepository(AttestationChallenge);
|
||||||
|
export const UserSecurityKeys = getRepository(UserSecurityKey);
|
||||||
export const UserPublickeys = getRepository(UserPublickey);
|
export const UserPublickeys = getRepository(UserPublickey);
|
||||||
export const UserLists = getCustomRepository(UserListRepository);
|
export const UserLists = getCustomRepository(UserListRepository);
|
||||||
export const UserListJoinings = getRepository(UserListJoining);
|
export const UserListJoinings = getRepository(UserListJoining);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import $ from 'cafy';
|
import $ from 'cafy';
|
||||||
import { EntityRepository, Repository, In } from 'typeorm';
|
import { EntityRepository, Repository, In } from 'typeorm';
|
||||||
import { User, ILocalUser, IRemoteUser } from '../entities/user';
|
import { User, ILocalUser, IRemoteUser } from '../entities/user';
|
||||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..';
|
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings } from '..';
|
||||||
import { ensure } from '../../prelude/ensure';
|
import { ensure } from '../../prelude/ensure';
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
import { SchemaType } from '../../misc/schema';
|
import { SchemaType } from '../../misc/schema';
|
||||||
|
@ -156,6 +156,11 @@ export class UserRepository extends Repository<User> {
|
||||||
detail: true
|
detail: true
|
||||||
}),
|
}),
|
||||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||||
|
securityKeys: profile!.twoFactorEnabled
|
||||||
|
? UserSecurityKeys.count({
|
||||||
|
userId: user.id
|
||||||
|
}).then(result => result >= 1)
|
||||||
|
: false,
|
||||||
twitter: profile!.twitter ? {
|
twitter: profile!.twitter ? {
|
||||||
id: profile!.twitterUserId,
|
id: profile!.twitterUserId,
|
||||||
screenName: profile!.twitterScreenName
|
screenName: profile!.twitterScreenName
|
||||||
|
@ -195,6 +200,15 @@ export class UserRepository extends Repository<User> {
|
||||||
clientData: profile!.clientData,
|
clientData: profile!.clientData,
|
||||||
email: profile!.email,
|
email: profile!.email,
|
||||||
emailVerified: profile!.emailVerified,
|
emailVerified: profile!.emailVerified,
|
||||||
|
securityKeysList: profile!.twoFactorEnabled
|
||||||
|
? UserSecurityKeys.find({
|
||||||
|
where: {
|
||||||
|
userId: user.id
|
||||||
|
},
|
||||||
|
select: ['id', 'name', 'lastUsed']
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|
||||||
...(relation ? {
|
...(relation ? {
|
||||||
|
|
422
src/server/api/2fa.ts
Normal file
422
src/server/api/2fa.ts
Normal file
|
@ -0,0 +1,422 @@
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import config from '../../config';
|
||||||
|
import * as jsrsasign from 'jsrsasign';
|
||||||
|
|
||||||
|
const ECC_PRELUDE = Buffer.from([0x04]);
|
||||||
|
const NULL_BYTE = Buffer.from([0]);
|
||||||
|
const PEM_PRELUDE = Buffer.from(
|
||||||
|
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android Safetynet attestations are signed with this cert:
|
||||||
|
const GSR2 = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
|
||||||
|
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
|
||||||
|
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
|
||||||
|
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
|
||||||
|
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
|
||||||
|
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
|
||||||
|
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
|
||||||
|
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
|
||||||
|
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
|
||||||
|
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
|
||||||
|
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
|
||||||
|
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
|
||||||
|
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
|
||||||
|
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
|
||||||
|
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
|
||||||
|
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
|
||||||
|
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
|
||||||
|
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
|
||||||
|
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
|
||||||
|
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
|
||||||
|
-----END CERTIFICATE-----\n`;
|
||||||
|
|
||||||
|
function base64URLDecode(source: string) {
|
||||||
|
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCertSubject(certificate: string) {
|
||||||
|
const subjectCert = new jsrsasign.X509();
|
||||||
|
subjectCert.readCertPEM(certificate);
|
||||||
|
|
||||||
|
const subjectString = subjectCert.getSubjectString();
|
||||||
|
const subjectFields = subjectString.slice(1).split('/');
|
||||||
|
|
||||||
|
const fields = {} as Record<string, string>;
|
||||||
|
for (const field of subjectFields) {
|
||||||
|
const eqIndex = field.indexOf('=');
|
||||||
|
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCertificateChain(certificates: string[]) {
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < certificates.length; i++) {
|
||||||
|
const Cert = certificates[i];
|
||||||
|
const certificate = new jsrsasign.X509();
|
||||||
|
certificate.readCertPEM(Cert);
|
||||||
|
|
||||||
|
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
|
||||||
|
|
||||||
|
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex, 0, [0]);
|
||||||
|
const algorithm = certificate.getSignatureAlgorithmField();
|
||||||
|
const signatureHex = certificate.getSignatureValueHex();
|
||||||
|
|
||||||
|
// Verify against CA
|
||||||
|
const Signature = new jsrsasign.crypto.Signature({alg: algorithm});
|
||||||
|
Signature.init(CACert);
|
||||||
|
Signature.updateHex(certStruct);
|
||||||
|
valid = valid && Signature.verify(signatureHex); // true if CA signed the certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
|
||||||
|
if (pemBuffer.length == 65 && pemBuffer[0] == 0x04) {
|
||||||
|
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
|
||||||
|
type = 'PUBLIC KEY';
|
||||||
|
}
|
||||||
|
const cert = pemBuffer.toString('base64');
|
||||||
|
|
||||||
|
const keyParts = [];
|
||||||
|
const max = Math.ceil(cert.length / 64);
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
keyParts.push(cert.substring(start, start + 64));
|
||||||
|
start += 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`-----BEGIN ${type}-----\n` +
|
||||||
|
keyParts.join('\n') +
|
||||||
|
`\n-----END ${type}-----\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hash(data: Buffer) {
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(data)
|
||||||
|
.digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyLogin({
|
||||||
|
publicKey,
|
||||||
|
authenticatorData,
|
||||||
|
clientDataJSON,
|
||||||
|
clientData,
|
||||||
|
signature,
|
||||||
|
challenge
|
||||||
|
}: {
|
||||||
|
publicKey: Buffer,
|
||||||
|
authenticatorData: Buffer,
|
||||||
|
clientDataJSON: Buffer,
|
||||||
|
clientData: any,
|
||||||
|
signature: Buffer,
|
||||||
|
challenge: string
|
||||||
|
}) {
|
||||||
|
if (clientData.type != 'webauthn.get') {
|
||||||
|
throw new Error('type is not webauthn.get');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash(clientData.challenge).toString('hex') != challenge) {
|
||||||
|
throw new Error('challenge mismatch');
|
||||||
|
}
|
||||||
|
if (clientData.origin != config.scheme + '://' + config.host) {
|
||||||
|
throw new Error('origin mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationData = Buffer.concat(
|
||||||
|
[authenticatorData, hash(clientDataJSON)],
|
||||||
|
32 + authenticatorData.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return crypto
|
||||||
|
.createVerify('SHA256')
|
||||||
|
.update(verificationData)
|
||||||
|
.verify(PEMString(publicKey), signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const procedures = {
|
||||||
|
none: {
|
||||||
|
verify({publicKey}: {publicKey: Map<number, Buffer>}) {
|
||||||
|
const negTwo = publicKey.get(-2);
|
||||||
|
|
||||||
|
if (!negTwo || negTwo.length != 32) {
|
||||||
|
throw new Error('invalid or no -2 key given');
|
||||||
|
}
|
||||||
|
const negThree = publicKey.get(-3);
|
||||||
|
if (!negThree || negThree.length != 32) {
|
||||||
|
throw new Error('invalid or no -3 key given');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyU2F = Buffer.concat(
|
||||||
|
[ECC_PRELUDE, negTwo, negThree],
|
||||||
|
1 + 32 + 32
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: publicKeyU2F,
|
||||||
|
valid: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'android-key': {
|
||||||
|
verify({
|
||||||
|
attStmt,
|
||||||
|
authenticatorData,
|
||||||
|
clientDataHash,
|
||||||
|
publicKey,
|
||||||
|
rpIdHash,
|
||||||
|
credentialId
|
||||||
|
}: {
|
||||||
|
attStmt: any,
|
||||||
|
authenticatorData: Buffer,
|
||||||
|
clientDataHash: Buffer,
|
||||||
|
publicKey: Map<number, any>;
|
||||||
|
rpIdHash: Buffer,
|
||||||
|
credentialId: Buffer,
|
||||||
|
}) {
|
||||||
|
if (attStmt.alg != -7) {
|
||||||
|
throw new Error('alg mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationData = Buffer.concat([
|
||||||
|
authenticatorData,
|
||||||
|
clientDataHash
|
||||||
|
]);
|
||||||
|
|
||||||
|
const attCert: Buffer = attStmt.x5c[0];
|
||||||
|
|
||||||
|
const negTwo = publicKey.get(-2);
|
||||||
|
|
||||||
|
if (!negTwo || negTwo.length != 32) {
|
||||||
|
throw new Error('invalid or no -2 key given');
|
||||||
|
}
|
||||||
|
const negThree = publicKey.get(-3);
|
||||||
|
if (!negThree || negThree.length != 32) {
|
||||||
|
throw new Error('invalid or no -3 key given');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyData = Buffer.concat(
|
||||||
|
[ECC_PRELUDE, negTwo, negThree],
|
||||||
|
1 + 32 + 32
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attCert.equals(publicKeyData)) {
|
||||||
|
throw new Error('public key mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = crypto
|
||||||
|
.createVerify('SHA256')
|
||||||
|
.update(verificationData)
|
||||||
|
.verify(PEMString(attCert), attStmt.sig);
|
||||||
|
|
||||||
|
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: isValid,
|
||||||
|
publicKey: publicKeyData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// what a stupid attestation
|
||||||
|
'android-safetynet': {
|
||||||
|
verify({
|
||||||
|
attStmt,
|
||||||
|
authenticatorData,
|
||||||
|
clientDataHash,
|
||||||
|
publicKey,
|
||||||
|
rpIdHash,
|
||||||
|
credentialId
|
||||||
|
}: {
|
||||||
|
attStmt: any,
|
||||||
|
authenticatorData: Buffer,
|
||||||
|
clientDataHash: Buffer,
|
||||||
|
publicKey: Map<number, any>;
|
||||||
|
rpIdHash: Buffer,
|
||||||
|
credentialId: Buffer,
|
||||||
|
}) {
|
||||||
|
const verificationData = hash(
|
||||||
|
Buffer.concat([authenticatorData, clientDataHash])
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwsParts = attStmt.response.toString('utf-8').split('.');
|
||||||
|
|
||||||
|
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
|
||||||
|
const response = JSON.parse(
|
||||||
|
base64URLDecode(jwsParts[1]).toString('utf-8')
|
||||||
|
);
|
||||||
|
const signature = jwsParts[2];
|
||||||
|
|
||||||
|
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
|
||||||
|
throw new Error('invalid nonce');
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateChain = header.x5c
|
||||||
|
.map(key => PEMString(key))
|
||||||
|
.concat([GSR2]);
|
||||||
|
|
||||||
|
if (getCertSubject(certificateChain[0]).CN != 'attest.android.com') {
|
||||||
|
throw new Error('invalid common name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyCertificateChain(certificateChain)) {
|
||||||
|
throw new Error('Invalid certificate chain!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatureBase = Buffer.from(
|
||||||
|
jwsParts[0] + '.' + jwsParts[1],
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const valid = crypto
|
||||||
|
.createVerify('sha256')
|
||||||
|
.update(signatureBase)
|
||||||
|
.verify(certificateChain[0], base64URLDecode(signature));
|
||||||
|
|
||||||
|
const negTwo = publicKey.get(-2);
|
||||||
|
|
||||||
|
if (!negTwo || negTwo.length != 32) {
|
||||||
|
throw new Error('invalid or no -2 key given');
|
||||||
|
}
|
||||||
|
const negThree = publicKey.get(-3);
|
||||||
|
if (!negThree || negThree.length != 32) {
|
||||||
|
throw new Error('invalid or no -3 key given');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyData = Buffer.concat(
|
||||||
|
[ECC_PRELUDE, negTwo, negThree],
|
||||||
|
1 + 32 + 32
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
valid,
|
||||||
|
publicKey: publicKeyData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
packed: {
|
||||||
|
verify({
|
||||||
|
attStmt,
|
||||||
|
authenticatorData,
|
||||||
|
clientDataHash,
|
||||||
|
publicKey,
|
||||||
|
rpIdHash,
|
||||||
|
credentialId
|
||||||
|
}: {
|
||||||
|
attStmt: any,
|
||||||
|
authenticatorData: Buffer,
|
||||||
|
clientDataHash: Buffer,
|
||||||
|
publicKey: Map<number, any>;
|
||||||
|
rpIdHash: Buffer,
|
||||||
|
credentialId: Buffer,
|
||||||
|
}) {
|
||||||
|
const verificationData = Buffer.concat([
|
||||||
|
authenticatorData,
|
||||||
|
clientDataHash
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (attStmt.x5c) {
|
||||||
|
const attCert = attStmt.x5c[0];
|
||||||
|
|
||||||
|
const validSignature = crypto
|
||||||
|
.createVerify('SHA256')
|
||||||
|
.update(verificationData)
|
||||||
|
.verify(PEMString(attCert), attStmt.sig);
|
||||||
|
|
||||||
|
const negTwo = publicKey.get(-2);
|
||||||
|
|
||||||
|
if (!negTwo || negTwo.length != 32) {
|
||||||
|
throw new Error('invalid or no -2 key given');
|
||||||
|
}
|
||||||
|
const negThree = publicKey.get(-3);
|
||||||
|
if (!negThree || negThree.length != 32) {
|
||||||
|
throw new Error('invalid or no -3 key given');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyData = Buffer.concat(
|
||||||
|
[ECC_PRELUDE, negTwo, negThree],
|
||||||
|
1 + 32 + 32
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: validSignature,
|
||||||
|
publicKey: publicKeyData
|
||||||
|
};
|
||||||
|
} else if (attStmt.ecdaaKeyId) {
|
||||||
|
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
|
||||||
|
throw new Error('ECDAA-Verify is not supported');
|
||||||
|
} else {
|
||||||
|
if (attStmt.alg != -7) throw new Error('alg mismatch');
|
||||||
|
|
||||||
|
throw new Error('self attestation is not supported');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'fido-u2f': {
|
||||||
|
verify({
|
||||||
|
attStmt,
|
||||||
|
authenticatorData,
|
||||||
|
clientDataHash,
|
||||||
|
publicKey,
|
||||||
|
rpIdHash,
|
||||||
|
credentialId
|
||||||
|
}: {
|
||||||
|
attStmt: any,
|
||||||
|
authenticatorData: Buffer,
|
||||||
|
clientDataHash: Buffer,
|
||||||
|
publicKey: Map<number, any>,
|
||||||
|
rpIdHash: Buffer,
|
||||||
|
credentialId: Buffer
|
||||||
|
}) {
|
||||||
|
const x5c: Buffer[] = attStmt.x5c;
|
||||||
|
if (x5c.length != 1) {
|
||||||
|
throw new Error('x5c length does not match expectation');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attCert = x5c[0];
|
||||||
|
|
||||||
|
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
|
||||||
|
|
||||||
|
const negTwo: Buffer = publicKey.get(-2);
|
||||||
|
|
||||||
|
if (!negTwo || negTwo.length != 32) {
|
||||||
|
throw new Error('invalid or no -2 key given');
|
||||||
|
}
|
||||||
|
const negThree: Buffer = publicKey.get(-3);
|
||||||
|
if (!negThree || negThree.length != 32) {
|
||||||
|
throw new Error('invalid or no -3 key given');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyU2F = Buffer.concat(
|
||||||
|
[ECC_PRELUDE, negTwo, negThree],
|
||||||
|
1 + 32 + 32
|
||||||
|
);
|
||||||
|
|
||||||
|
const verificationData = Buffer.concat([
|
||||||
|
NULL_BYTE,
|
||||||
|
rpIdHash,
|
||||||
|
clientDataHash,
|
||||||
|
credentialId,
|
||||||
|
publicKeyU2F
|
||||||
|
]);
|
||||||
|
|
||||||
|
const validSignature = crypto
|
||||||
|
.createVerify('SHA256')
|
||||||
|
.update(verificationData)
|
||||||
|
.verify(PEMString(attCert), attStmt.sig);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: validSignature,
|
||||||
|
publicKey: publicKeyU2F
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
67
src/server/api/endpoints/i/2fa/getkeys.ts
Normal file
67
src/server/api/endpoints/i/2fa/getkeys.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import $ from 'cafy';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import define from '../../../define';
|
||||||
|
import { UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../../../models';
|
||||||
|
import { ensure } from '../../../../../prelude/ensure';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { hash } from '../../../2fa';
|
||||||
|
import { genId } from '../../../../../misc/gen-id';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
secure: true,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
password: {
|
||||||
|
validator: $.str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomBytes = promisify(crypto.randomBytes);
|
||||||
|
|
||||||
|
export default define(meta, async (ps, user) => {
|
||||||
|
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||||
|
|
||||||
|
// Compare password
|
||||||
|
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||||
|
|
||||||
|
if (!same) {
|
||||||
|
throw new Error('incorrect password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await UserSecurityKeys.find({
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
throw new Error('no keys found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge,
|
||||||
|
challengeId,
|
||||||
|
securityKeys: keys.map(key => ({
|
||||||
|
id: key.id
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
151
src/server/api/endpoints/i/2fa/key-done.ts
Normal file
151
src/server/api/endpoints/i/2fa/key-done.ts
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
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';
|
||||||
|
import { ensure } from '../../../../../prelude/ensure';
|
||||||
|
import config from '../../../../../config';
|
||||||
|
import { procedures, hash } from '../../../2fa';
|
||||||
|
import { publishMainStream } from '../../../../../services/stream';
|
||||||
|
|
||||||
|
const cborDecodeFirst = promisify(cbor.decodeFirst);
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
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.findOne(user.id).then(ensure);
|
||||||
|
|
||||||
|
// 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[attestation.fmt]) {
|
||||||
|
throw new Error('unsupported fmt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationData = procedures[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
|
||||||
|
};
|
||||||
|
});
|
60
src/server/api/endpoints/i/2fa/register-key.ts
Normal file
60
src/server/api/endpoints/i/2fa/register-key.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import $ from 'cafy';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import define from '../../../define';
|
||||||
|
import { UserProfiles, AttestationChallenges } from '../../../../../models';
|
||||||
|
import { ensure } from '../../../../../prelude/ensure';
|
||||||
|
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,
|
||||||
|
|
||||||
|
secure: true,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
password: {
|
||||||
|
validator: $.str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default define(meta, async (ps, user) => {
|
||||||
|
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
});
|
46
src/server/api/endpoints/i/2fa/remove-key.ts
Normal file
46
src/server/api/endpoints/i/2fa/remove-key.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import $ from 'cafy';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import define from '../../../define';
|
||||||
|
import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models';
|
||||||
|
import { ensure } from '../../../../../prelude/ensure';
|
||||||
|
import { publishMainStream } from '../../../../../services/stream';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
secure: true,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
password: {
|
||||||
|
validator: $.str
|
||||||
|
},
|
||||||
|
credentialId: {
|
||||||
|
validator: $.str
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default define(meta, async (ps, user) => {
|
||||||
|
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||||
|
|
||||||
|
// 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 {};
|
||||||
|
});
|
|
@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy';
|
||||||
import { publishMainStream } from '../../../services/stream';
|
import { publishMainStream } from '../../../services/stream';
|
||||||
import signin from '../common/signin';
|
import signin from '../common/signin';
|
||||||
import config from '../../../config';
|
import config from '../../../config';
|
||||||
import { Users, Signins, UserProfiles } from '../../../models';
|
import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models';
|
||||||
import { ILocalUser } from '../../../models/entities/user';
|
import { ILocalUser } from '../../../models/entities/user';
|
||||||
import { genId } from '../../../misc/gen-id';
|
import { genId } from '../../../misc/gen-id';
|
||||||
import { ensure } from '../../../prelude/ensure';
|
import { ensure } from '../../../prelude/ensure';
|
||||||
|
import { verifyLogin, hash } from '../2fa';
|
||||||
|
|
||||||
export default async (ctx: Koa.BaseContext) => {
|
export default async (ctx: Koa.BaseContext) => {
|
||||||
ctx.set('Access-Control-Allow-Origin', config.url);
|
ctx.set('Access-Control-Allow-Origin', config.url);
|
||||||
|
@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => {
|
||||||
// Compare password
|
// Compare password
|
||||||
const same = await bcrypt.compare(password, profile.password!);
|
const same = await bcrypt.compare(password, profile.password!);
|
||||||
|
|
||||||
if (same) {
|
async function fail(status?: number, failure?: {error: string}) {
|
||||||
if (profile.twoFactorEnabled) {
|
// Append signin history
|
||||||
const verified = (speakeasy as any).totp.verify({
|
const record = await Signins.save({
|
||||||
secret: profile.twoFactorSecret,
|
id: genId(),
|
||||||
encoding: 'base32',
|
createdAt: new Date(),
|
||||||
token: token
|
userId: user.id,
|
||||||
});
|
ip: ctx.ip,
|
||||||
|
headers: ctx.headers,
|
||||||
if (verified) {
|
success: !!(status || failure)
|
||||||
signin(ctx, user);
|
|
||||||
} else {
|
|
||||||
ctx.throw(403, {
|
|
||||||
error: 'invalid token'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
signin(ctx, user);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ctx.throw(403, {
|
|
||||||
error: 'incorrect password'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Publish signin event
|
||||||
|
publishMainStream(user.id, 'signin', await Signins.pack(record));
|
||||||
|
|
||||||
|
if (status && failure) {
|
||||||
|
ctx.throw(status, failure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append signin history
|
if (!same) {
|
||||||
const record = await Signins.save({
|
await fail(403, {
|
||||||
id: genId(),
|
error: 'incorrect password'
|
||||||
createdAt: new Date(),
|
});
|
||||||
userId: user.id,
|
return;
|
||||||
ip: ctx.ip,
|
}
|
||||||
headers: ctx.headers,
|
|
||||||
success: same
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publish signin event
|
if (!profile.twoFactorEnabled) {
|
||||||
publishMainStream(user.id, 'signin', await Signins.pack(record));
|
signin(ctx, user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const verified = (speakeasy as any).totp.verify({
|
||||||
|
secret: profile.twoFactorSecret,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: token
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
signin(ctx, user);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await fail(403, {
|
||||||
|
error: 'invalid token'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
|
||||||
|
const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
|
||||||
|
const challenge = await AttestationChallenges.findOne({
|
||||||
|
userId: user.id,
|
||||||
|
id: body.challengeId,
|
||||||
|
registrationChallenge: false,
|
||||||
|
challenge: hash(clientData.challenge).toString('hex')
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
await fail(403, {
|
||||||
|
error: 'non-existent challenge'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AttestationChallenges.delete({
|
||||||
|
userId: user.id,
|
||||||
|
id: body.challengeId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
|
||||||
|
await fail(403, {
|
||||||
|
error: 'non-existent challenge'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const securityKey = await UserSecurityKeys.findOne({
|
||||||
|
id: Buffer.from(
|
||||||
|
body.credentialId
|
||||||
|
.replace(/\-/g, '+')
|
||||||
|
.replace(/_/g, '/'),
|
||||||
|
'base64'
|
||||||
|
).toString('hex')
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!securityKey) {
|
||||||
|
await fail(403, {
|
||||||
|
error: 'invalid credentialId'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = verifyLogin({
|
||||||
|
publicKey: Buffer.from(securityKey.publicKey, 'hex'),
|
||||||
|
authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
|
||||||
|
clientDataJSON,
|
||||||
|
clientData,
|
||||||
|
signature: Buffer.from(body.signature, 'hex'),
|
||||||
|
challenge: challenge.challenge
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
signin(ctx, user);
|
||||||
|
} else {
|
||||||
|
await fail(403, {
|
||||||
|
error: 'invalid challenge data'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fail();
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue