This commit is contained in:
syuilo 2017-12-08 22:57:58 +09:00
parent 0de62522a8
commit 6bc499f657
11 changed files with 221 additions and 4 deletions

View file

@ -137,6 +137,7 @@ common:
mk-signin: mk-signin:
username: "Username" username: "Username"
password: "Password" password: "Password"
token: "Token"
signing-in: "Signing in..." signing-in: "Signing in..."
signin: "Sign in" signin: "Sign in"
@ -295,6 +296,17 @@ desktop:
not-match: "New password not matched" not-match: "New password not matched"
changed: "Password updated successfully" 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: mk-post-form:
post-placeholder: "What's happening?" post-placeholder: "What's happening?"
reply-placeholder: "Reply to this post..." reply-placeholder: "Reply to this post..."
@ -327,7 +339,9 @@ desktop:
next: "Next post" next: "Next post"
mk-settings: mk-settings:
security: "Security"
password: "Password" password: "Password"
2fa: "Two-factor authentication"
mk-timeline-post: mk-timeline-post:
reposted-by: "Reposted by {}" reposted-by: "Reposted by {}"

View file

@ -137,6 +137,7 @@ common:
mk-signin: mk-signin:
username: "ユーザー名" username: "ユーザー名"
password: "パスワード" password: "パスワード"
token: "トークン"
signing-in: "やってます..." signing-in: "やってます..."
signin: "サインイン" signin: "サインイン"
@ -295,6 +296,17 @@ desktop:
not-match: "新しいパスワードが一致しません" not-match: "新しいパスワードが一致しません"
changed: "パスワードを変更しました" changed: "パスワードを変更しました"
mk-2fa-setting:
register: "デバイスを登録する"
enter-password: "パスワードを入力してください"
authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
howtoinstall: "インストール方法はこちら"
scan: "次に、表示されているQRコードをスキャンします:"
done: "お使いのデバイスに表示されているトークンを入力して完了します:"
submit: "完了"
success: "設定が完了しました!"
failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
mk-post-form: mk-post-form:
post-placeholder: "いまどうしてる?" post-placeholder: "いまどうしてる?"
reply-placeholder: "この投稿への返信..." reply-placeholder: "この投稿への返信..."
@ -327,7 +339,9 @@ desktop:
next: "次の投稿" next: "次の投稿"
mk-settings: mk-settings:
security: "セキュリティ"
password: "パスワード" password: "パスワード"
2fa: "二段階認証"
mk-timeline-post: mk-timeline-post:
reposted-by: "{}がRepost" reposted-by: "{}がRepost"

View file

@ -62,6 +62,7 @@
"@types/node": "8.0.57", "@types/node": "8.0.57",
"@types/page": "1.5.32", "@types/page": "1.5.32",
"@types/proxy-addr": "2.0.0", "@types/proxy-addr": "2.0.0",
"@types/qrcode": "^0.8.0",
"@types/ratelimiter": "2.1.28", "@types/ratelimiter": "2.1.28",
"@types/redis": "2.8.1", "@types/redis": "2.8.1",
"@types/request": "2.0.8", "@types/request": "2.0.8",
@ -69,6 +70,7 @@
"@types/riot": "3.6.1", "@types/riot": "3.6.1",
"@types/seedrandom": "2.4.27", "@types/seedrandom": "2.4.27",
"@types/serve-favicon": "2.2.30", "@types/serve-favicon": "2.2.30",
"@types/speakeasy": "^2.0.1",
"@types/tmp": "0.0.33", "@types/tmp": "0.0.33",
"@types/uuid": "3.4.3", "@types/uuid": "3.4.3",
"@types/webpack": "3.8.1", "@types/webpack": "3.8.1",
@ -134,6 +136,7 @@
"prominence": "0.2.0", "prominence": "0.2.0",
"proxy-addr": "2.0.2", "proxy-addr": "2.0.2",
"pug": "2.0.0-rc.4", "pug": "2.0.0-rc.4",
"qrcode": "^1.0.0",
"ratelimiter": "3.0.3", "ratelimiter": "3.0.3",
"recaptcha-promise": "0.1.3", "recaptcha-promise": "0.1.3",
"reconnecting-websocket": "3.2.2", "reconnecting-websocket": "3.2.2",
@ -147,6 +150,7 @@
"seedrandom": "^2.4.3", "seedrandom": "^2.4.3",
"serve-favicon": "2.4.5", "serve-favicon": "2.4.5",
"sortablejs": "1.7.0", "sortablejs": "1.7.0",
"speakeasy": "^2.0.0",
"string-replace-webpack-plugin": "0.1.3", "string-replace-webpack-plugin": "0.1.3",
"style-loader": "0.19.0", "style-loader": "0.19.0",
"stylus": "0.54.5", "stylus": "0.54.5",

View file

@ -155,6 +155,14 @@ const endpoints: Endpoint[] = [
name: 'i', name: 'i',
withCredential: true withCredential: true
}, },
{
name: 'i/2fa/register',
withCredential: true
},
{
name: 'i/2fa/done',
withCredential: true
},
{ {
name: 'i/update', name: 'i/update',
withCredential: true, withCredential: true,

View file

@ -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();
});

View file

@ -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
});
});
});

View file

@ -72,6 +72,8 @@ export type IUser = {
is_pro: boolean; is_pro: boolean;
is_suspended: boolean; is_suspended: boolean;
keywords: string[]; keywords: string[];
two_factor_secret: string;
two_factor_enabled: boolean;
}; };
export function init(user): IUser { export function init(user): IUser {

View file

@ -1,5 +1,6 @@
import * as express from 'express'; import * as express from 'express';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy';
import { default as User, IUser } from '../models/user'; import { default as User, IUser } from '../models/user';
import Signin from '../models/signin'; import Signin from '../models/signin';
import serialize from '../serializers/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 username = req.body['username'];
const password = req.body['password']; const password = req.body['password'];
const token = req.body['token'];
if (typeof username != 'string') { if (typeof username != 'string') {
res.sendStatus(400); res.sendStatus(400);
@ -22,6 +24,11 @@ export default async (req: express.Request, res: express.Response) => {
return; return;
} }
if (token != null && typeof token != 'string') {
res.sendStatus(400);
return;
}
// Fetch user // Fetch user
const user: IUser = await User.findOne({ const user: IUser = await User.findOne({
username_lower: username.toLowerCase() 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); const same = await bcrypt.compare(password, user.password);
if (same) { 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 { } else {
res.status(400).send({ res.status(400).send({
error: 'incorrect password' error: 'incorrect password'

View file

@ -78,6 +78,8 @@ export default (
// Remove private properties // Remove private properties
delete _user.password; delete _user.password;
delete _user.token; delete _user.token;
delete _user.two_factor_temp_secret;
delete _user.two_factor_secret;
delete _user.username_lower; delete _user.username_lower;
if (_user.twitter) { if (_user.twitter) {
delete _user.twitter.access_token; delete _user.twitter.access_token;
@ -91,6 +93,10 @@ export default (
delete _user.client_settings; delete _user.client_settings;
} }
if (!opts.detail) {
delete _user.two_factor_enabled;
}
_user.avatar_url = _user.avatar_id != null _user.avatar_url = _user.avatar_id != null
? `${config.drive_url}/${_user.avatar_id}` ? `${config.drive_url}/${_user.avatar_id}`
: `${config.drive_url}/default-avatar.jpg`; : `${config.drive_url}/default-avatar.jpg`;

View file

@ -6,6 +6,9 @@
<label class="password"> <label class="password">
<input ref="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required="required"/>%fa:lock% <input ref="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required="required"/>%fa:lock%
</label> </label>
<label class="token" if={ user && user.two_factor_enabled }>
<input ref="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required="required"/>%fa:lock%
</label>
<button type="submit" disabled={ signing }>{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }</button> <button type="submit" disabled={ signing }>{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }</button>
</form> </form>
<style> <style>
@ -39,6 +42,7 @@
input[type=text] input[type=text]
input[type=password] input[type=password]
input[type=number]
user-select text user-select text
display inline-block display inline-block
cursor auto cursor auto
@ -123,6 +127,10 @@
this.refs.password.focus(); this.refs.password.focus();
return false; return false;
} }
if (this.user && this.user.two_factor_enabled && this.refs.token.value == '') {
this.refs.token.focus();
return false;
}
this.update({ this.update({
signing: true signing: true
@ -130,7 +138,8 @@
this.api('signin', { this.api('signin', {
username: this.refs.username.value, username: this.refs.username.value,
password: this.refs.password.value password: this.refs.password.value,
token: this.user && this.user.two_factor_enabled ? this.refs.token.value : undefined
}).then(() => { }).then(() => {
location.reload(); location.reload();
}).catch(() => { }).catch(() => {

View file

@ -7,7 +7,7 @@
<p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }>%fa:puzzle-piece .fw%アプリ</p> <p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }>%fa:puzzle-piece .fw%アプリ</p>
<p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }>%fa:B twitter .fw%Twitter</p> <p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }>%fa:B twitter .fw%Twitter</p>
<p class={ active: page == 'signin' } onmousedown={ setPage.bind(null, 'signin') }>%fa:sign-in-alt .fw%ログイン履歴</p> <p class={ active: page == 'signin' } onmousedown={ setPage.bind(null, 'signin') }>%fa:sign-in-alt .fw%ログイン履歴</p>
<p class={ active: page == 'password' } onmousedown={ setPage.bind(null, 'password') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.password%</p> <p class={ active: page == 'security' } onmousedown={ setPage.bind(null, 'security') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p>
<p class={ active: page == 'api' } onmousedown={ setPage.bind(null, 'api') }>%fa:key .fw%API</p> <p class={ active: page == 'api' } onmousedown={ setPage.bind(null, 'api') }>%fa:key .fw%API</p>
</div> </div>
<div class="pages"> <div class="pages">
@ -59,11 +59,16 @@
<mk-signin-history/> <mk-signin-history/>
</section> </section>
<section class="password" show={ page == 'password' }> <section class="password" show={ page == 'security' }>
<h1>%i18n:desktop.tags.mk-settings.password%</h1> <h1>%i18n:desktop.tags.mk-settings.password%</h1>
<mk-password-setting/> <mk-password-setting/>
</section> </section>
<section class="2fa" show={ page == 'security' }>
<h1>%i18n:desktop.tags.mk-settings.2fa%</h1>
<mk-2fa-setting/>
</section>
<section class="api" show={ page == 'api' }> <section class="api" show={ page == 'api' }>
<h1>API</h1> <h1>API</h1>
<mk-api-info/> <mk-api-info/>
@ -285,3 +290,50 @@
}; };
</script> </script>
</mk-password-setting> </mk-password-setting>
<mk-2fa-setting>
<p><button onclick={ register }>%i18n:desktop.tags.mk-2fa-setting.register%</button></p>
<div if={ data }>
<ol>
<li>%i18n:desktop.tags.mk-2fa-setting.authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:desktop.tags.mk-2fa-setting.howtoinstall%</a></li>
<li>%i18n:desktop.tags.mk-2fa-setting.scan%<br><img src={ data.qr }></li>
<li>%i18n:desktop.tags.mk-2fa-setting.done%<br>
<input type="number" ref="token"><button onclick={ submit }>%i18n:desktop.tags.mk-2fa-setting.submit%</button>
</li>
</ol>
</div>
<style>
:scope
display block
color #4a535a
</style>
<script>
import passwordDialog from '../scripts/password-dialog';
import notify from '../scripts/notify';
this.mixin('api');
this.register = () => {
passwordDialog('%i18n:desktop.tags.mk-2fa-setting.enter-password%', password => {
this.api('i/2fa/register', {
password: password
}).then(data => {
this.update({
data: data
});
});
});
};
this.submit = () => {
this.api('i/2fa/done', {
token: this.refs.token.value
}).then(() => {
notify('%i18n:desktop.tags.mk-2fa-setting.success%');
}).catch(() => {
notify('%i18n:desktop.tags.mk-2fa-setting.failed%');
});
};
</script>
</mk-2fa-setting>