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:
@@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy';
|
||||
import { publishMainStream } from '../../../services/stream';
|
||||
import signin from '../common/signin';
|
||||
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 { genId } from '../../../misc/gen-id';
|
||||
import { ensure } from '../../../prelude/ensure';
|
||||
import { verifyLogin, hash } from '../2fa';
|
||||
|
||||
export default async (ctx: Koa.BaseContext) => {
|
||||
ctx.set('Access-Control-Allow-Origin', config.url);
|
||||
@@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => {
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(password, profile.password!);
|
||||
|
||||
if (same) {
|
||||
if (profile.twoFactorEnabled) {
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: 'base32',
|
||||
token: token
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
signin(ctx, user);
|
||||
} else {
|
||||
ctx.throw(403, {
|
||||
error: 'invalid token'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
signin(ctx, user);
|
||||
}
|
||||
} else {
|
||||
ctx.throw(403, {
|
||||
error: 'incorrect password'
|
||||
async function fail(status?: number, failure?: {error: string}) {
|
||||
// Append signin history
|
||||
const record = await Signins.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
ip: ctx.ip,
|
||||
headers: ctx.headers,
|
||||
success: !!(status || failure)
|
||||
});
|
||||
|
||||
// Publish signin event
|
||||
publishMainStream(user.id, 'signin', await Signins.pack(record));
|
||||
|
||||
if (status && failure) {
|
||||
ctx.throw(status, failure);
|
||||
}
|
||||
}
|
||||
|
||||
// Append signin history
|
||||
const record = await Signins.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
ip: ctx.ip,
|
||||
headers: ctx.headers,
|
||||
success: same
|
||||
});
|
||||
if (!same) {
|
||||
await fail(403, {
|
||||
error: 'incorrect password'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish signin event
|
||||
publishMainStream(user.id, 'signin', await Signins.pack(record));
|
||||
if (!profile.twoFactorEnabled) {
|
||||
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();
|
||||
};
|
||||
|
Reference in New Issue
Block a user