From 6bc499f6579a9a248430748f9a69f3e5873a5ed3 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 8 Dec 2017 22:57:58 +0900 Subject: [PATCH] #967 --- locales/en.yml | 14 +++++++ locales/ja.yml | 14 +++++++ package.json | 4 ++ src/api/endpoints.ts | 8 ++++ src/api/endpoints/i/2fa/done.ts | 37 ++++++++++++++++++ src/api/endpoints/i/2fa/register.ts | 48 +++++++++++++++++++++++ src/api/models/user.ts | 2 + src/api/private/signin.ts | 25 +++++++++++- src/api/serializers/user.ts | 6 +++ src/web/app/common/tags/signin.tag | 11 +++++- src/web/app/desktop/tags/settings.tag | 56 ++++++++++++++++++++++++++- 11 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/api/endpoints/i/2fa/done.ts create mode 100644 src/api/endpoints/i/2fa/register.ts diff --git a/locales/en.yml b/locales/en.yml index 3009aad8c..6b39b4b8a 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -137,6 +137,7 @@ common: mk-signin: username: "Username" password: "Password" + token: "Token" signing-in: "Signing in..." signin: "Sign in" @@ -295,6 +296,17 @@ desktop: not-match: "New password not matched" changed: "Password updated successfully" + mk-2fa-setting: + register: "Register a device" + enter-password: "Enter the password" + authenticator: "First, you need install Google Authenticator to your device:" + howtoinstall: "How to install" + scan: "Next, please scan displayed QR code:" + done: "Please enter the token displaying in your device:" + submit: "Submit" + success: "Setup completed successfully!" + failed: "Failed to setup. please ensure that the token is correct." + mk-post-form: post-placeholder: "What's happening?" reply-placeholder: "Reply to this post..." @@ -327,7 +339,9 @@ desktop: next: "Next post" mk-settings: + security: "Security" password: "Password" + 2fa: "Two-factor authentication" mk-timeline-post: reposted-by: "Reposted by {}" diff --git a/locales/ja.yml b/locales/ja.yml index cdfcd6385..672d4ab40 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -137,6 +137,7 @@ common: mk-signin: username: "ユーザー名" password: "パスワード" + token: "トークン" signing-in: "やってます..." signin: "サインイン" @@ -295,6 +296,17 @@ desktop: not-match: "新しいパスワードが一致しません" changed: "パスワードを変更しました" + mk-2fa-setting: + register: "デバイスを登録する" + enter-password: "パスワードを入力してください" + authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:" + howtoinstall: "インストール方法はこちら" + scan: "次に、表示されているQRコードをスキャンします:" + done: "お使いのデバイスに表示されているトークンを入力して完了します:" + submit: "完了" + success: "設定が完了しました!" + failed: "設定に失敗しました。トークンに誤りがないかご確認ください。" + mk-post-form: post-placeholder: "いまどうしてる?" reply-placeholder: "この投稿への返信..." @@ -327,7 +339,9 @@ desktop: next: "次の投稿" mk-settings: + security: "セキュリティ" password: "パスワード" + 2fa: "二段階認証" mk-timeline-post: reposted-by: "{}がRepost" diff --git a/package.json b/package.json index 6521d65e6..451bfc982 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/node": "8.0.57", "@types/page": "1.5.32", "@types/proxy-addr": "2.0.0", + "@types/qrcode": "^0.8.0", "@types/ratelimiter": "2.1.28", "@types/redis": "2.8.1", "@types/request": "2.0.8", @@ -69,6 +70,7 @@ "@types/riot": "3.6.1", "@types/seedrandom": "2.4.27", "@types/serve-favicon": "2.2.30", + "@types/speakeasy": "^2.0.1", "@types/tmp": "0.0.33", "@types/uuid": "3.4.3", "@types/webpack": "3.8.1", @@ -134,6 +136,7 @@ "prominence": "0.2.0", "proxy-addr": "2.0.2", "pug": "2.0.0-rc.4", + "qrcode": "^1.0.0", "ratelimiter": "3.0.3", "recaptcha-promise": "0.1.3", "reconnecting-websocket": "3.2.2", @@ -147,6 +150,7 @@ "seedrandom": "^2.4.3", "serve-favicon": "2.4.5", "sortablejs": "1.7.0", + "speakeasy": "^2.0.0", "string-replace-webpack-plugin": "0.1.3", "style-loader": "0.19.0", "stylus": "0.54.5", diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 06fb9a64a..49871c0ce 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -155,6 +155,14 @@ const endpoints: Endpoint[] = [ name: 'i', withCredential: true }, + { + name: 'i/2fa/register', + withCredential: true + }, + { + name: 'i/2fa/done', + withCredential: true + }, { name: 'i/update', withCredential: true, diff --git a/src/api/endpoints/i/2fa/done.ts b/src/api/endpoints/i/2fa/done.ts new file mode 100644 index 000000000..0b36033bb --- /dev/null +++ b/src/api/endpoints/i/2fa/done.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import User from '../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + const _token = token.replace(/\s/g, ''); + + if (user.two_factor_temp_secret == null) { + return rej('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: user.two_factor_temp_secret, + encoding: 'base32', + token: _token + }); + + if (!verified) { + return rej('not verified'); + } + + await User.update(user._id, { + $set: { + two_factor_secret: user.two_factor_temp_secret, + two_factor_enabled: true + } + }); + + res(); +}); diff --git a/src/api/endpoints/i/2fa/register.ts b/src/api/endpoints/i/2fa/register.ts new file mode 100644 index 000000000..c2b5037a2 --- /dev/null +++ b/src/api/endpoints/i/2fa/register.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import User from '../../../models/user'; +import config from '../../../../conf'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await User.update(user._id, { + $set: { + two_factor_temp_secret: secret.base32 + } + }); + + // Get the data URL of the authenticator URL + QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + }), (err, data_url) => { + res({ + qr: data_url, + secret: secret.base32, + label: user.username, + issuer: config.host + }); + }); +}); diff --git a/src/api/models/user.ts b/src/api/models/user.ts index b2f3af09f..018979158 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -72,6 +72,8 @@ export type IUser = { is_pro: boolean; is_suspended: boolean; keywords: string[]; + two_factor_secret: string; + two_factor_enabled: boolean; }; export function init(user): IUser { diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts index 0ebf8d6aa..7376921e2 100644 --- a/src/api/private/signin.ts +++ b/src/api/private/signin.ts @@ -1,5 +1,6 @@ import * as express from 'express'; import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; import { default as User, IUser } from '../models/user'; import Signin from '../models/signin'; import serialize from '../serializers/signin'; @@ -11,6 +12,7 @@ export default async (req: express.Request, res: express.Response) => { const username = req.body['username']; const password = req.body['password']; + const token = req.body['token']; if (typeof username != 'string') { res.sendStatus(400); @@ -22,6 +24,11 @@ export default async (req: express.Request, res: express.Response) => { return; } + if (token != null && typeof token != 'string') { + res.sendStatus(400); + return; + } + // Fetch user const user: IUser = await User.findOne({ username_lower: username.toLowerCase() @@ -43,7 +50,23 @@ export default async (req: express.Request, res: express.Response) => { const same = await bcrypt.compare(password, user.password); if (same) { - signin(res, user, false); + if (user.two_factor_enabled) { + const verified = (speakeasy as any).totp.verify({ + secret: user.two_factor_secret, + encoding: 'base32', + token: token + }); + + if (verified) { + signin(res, user, false); + } else { + res.status(400).send({ + error: 'invalid token' + }); + } + } else { + signin(res, user, false); + } } else { res.status(400).send({ error: 'incorrect password' diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts index 3d8415660..fe924911c 100644 --- a/src/api/serializers/user.ts +++ b/src/api/serializers/user.ts @@ -78,6 +78,8 @@ export default ( // Remove private properties delete _user.password; delete _user.token; + delete _user.two_factor_temp_secret; + delete _user.two_factor_secret; delete _user.username_lower; if (_user.twitter) { delete _user.twitter.access_token; @@ -91,6 +93,10 @@ export default ( delete _user.client_settings; } + if (!opts.detail) { + delete _user.two_factor_enabled; + } + _user.avatar_url = _user.avatar_id != null ? `${config.drive_url}/${_user.avatar_id}` : `${config.drive_url}/default-avatar.jpg`; diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag index f25d99974..f5a2be94e 100644 --- a/src/web/app/common/tags/signin.tag +++ b/src/web/app/common/tags/signin.tag @@ -6,6 +6,9 @@ + + +