Skip to content

Commit

Permalink
FEATURE: Add support for two factor authentication.
Browse files Browse the repository at this point in the history
  • Loading branch information
erickguan committed Mar 19, 2015
1 parent 85df743 commit 08cec58
Show file tree
Hide file tree
Showing 26 changed files with 563 additions and 22 deletions.
3 changes: 2 additions & 1 deletion Gemfile
Expand Up @@ -148,7 +148,8 @@ gem 'oj'
gem 'pg'
gem 'pry-rails', require: false
gem 'rake'

gem 'rotp'
gem 'rqrcode'

gem 'rest-client'
gem 'rinku'
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Expand Up @@ -343,6 +343,8 @@ GEM
netrc (~> 0.7)
rinku (1.7.3)
rmmseg-cpp (0.2.9)
rotp (2.1.0)
rqrcode (0.4.2)
rspec (2.99.0)
rspec-core (~> 2.99.0)
rspec-expectations (~> 2.99.0)
Expand Down Expand Up @@ -525,6 +527,8 @@ DEPENDENCIES
rest-client
rinku
rmmseg-cpp
rotp
rqrcode
rspec (= 2.99.0)
rspec-given
rspec-rails
Expand Down
3 changes: 3 additions & 0 deletions app/assets/javascripts/discourse/controllers/login.js.es6
Expand Up @@ -71,6 +71,9 @@ export default DiscourseController.extend(ModalFunctionality, {
});
}
self.flash(result.error, 'error');
} else if (result.twoFactorAuthentication) {
self.set('loggingIn', false);
self.send('showTwoFactorAuthenticationCodeVerifier');
} else {
self.set('loggedIn', true);
// Trigger the browser's password manager using the hidden static login form:
Expand Down
Expand Up @@ -42,6 +42,10 @@ export default ObjectController.extend(CanCheckEmails, {
return !this.siteSettings.enable_sso && this.siteSettings.enable_local_logins;
}.property(),

canChangeTwoFactorAuthentication: function() {
return this.get('canChangePassword') && this.siteSettings.enable_two_factor_authentication;
}.property(),

canReceiveDigest: function() {
return !this.siteSettings.disable_digest_emails;
}.property(),
Expand Down
@@ -0,0 +1,61 @@
export default Ember.ObjectController.extend({
qr: Em.computed.alias('twoFactorAuthenticationData.modules'),
enabledTwoFactorAuthentication: Em.computed.alias('enabled_two_factor_authentication'),

appName: function() {
const data = this.get('twoFactorAuthenticationData.data');
return data ? data.match(/otpauth:\/\/totp\/(\S+)\?secret=(\S+)/)[1] : null;
}.property('twoFactorAuthenticationData'),

secret: function() {
const data = this.get('twoFactorAuthenticationData.data');
return data ? data.match(/otpauth:\/\/totp\/(\S+)\?secret=(\S+)/)[2] : null;
}.property('twoFactorAuthenticationData'),

savingStatus: function() {
if (this.get('saving')) {
return I18n.t('saving');
} else {
return I18n.t('save');
}
}.property('saving'),

actions: {
save() {
this.setProperties({ saved: false, saving: true });

const self = this;
Discourse.ajax(this.get('model.path') + '/preferences/two-factor-authentication', {
type: 'PUT',
data: {
secret: self.get('secret'),
code: self.get('code')
}
}).then(function() {
self.setProperties({
saved: true,
saving: false,
enabledTwoFactorAuthentication: true
});
}).catch(function() {
self.set('saving', false);
bootbox.alert(I18n.t('user.two_factor_authentication.configuration.incorret_code'));
});
},

revoke() {
const self = this;
Discourse.ajax(this.get('model.path') + '/preferences/revoke-two-factor-authentication', {
type: 'PUT',
data: { revoke: true }
}).then(function() {
self.setProperties({
enabledTwoFactorAuthentication: false
});
}).catch(function() {
bootbox.alert(I18n.t('generic_error'));
});
}

}
});
@@ -0,0 +1,65 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import DiscourseController from 'discourse/controllers/controller';

export default DiscourseController.extend(ModalFunctionality, {
needs: ['modal', 'application'],
authenticate: null,
loggingIn: false,
loggedIn: false,

loginRequired: Em.computed.alias('controllers.application.loginRequired'),

resetForm() {
this.set('authenticate', null);
this.set('loggingIn', false);
this.set('loggedIn', false);
},

loginButtonText: function() {
return this.get('loggingIn') ? I18n.t('login.logging_in') : I18n.t('login.title');
}.property('loggingIn'),

actions: {
verify() {
var self = this;

if (this.blank('twoFactorAuthenticationCode')) {
self.flash(I18n.t('login.blank_code'), 'error');
return;
}

this.set('loggingIn', true);

Discourse.ajax("/session/verify_two_factor_authentication_code", {
data: { code: this.get('twoFactorAuthenticationCode') },
type: 'POST'
}).then(function(result) {
// Successful login
if (result.error) {
self.set('loggingIn', false);
self.flash(result.error, 'error');
} else {
self.set('loggedIn', true);
var $hidden_login_form = $('#hidden-login-form');
var destinationUrl = $.cookie('destination_url');
if (self.get('loginRequired') && destinationUrl) {
// redirect client to the original URL
$.cookie('destination_url', null);
$hidden_login_form.find('input[name=redirect]').val(destinationUrl);
} else {
$hidden_login_form.find('input[name=redirect]').val(window.location.href);
}
$hidden_login_form.submit();
}

}, function() {
// Failed to login
self.flash(I18n.t('login.error'), 'error');
self.set('loggingIn', false);
});

return false;
}
}

});
Expand Up @@ -80,6 +80,7 @@ export default function() {
this.route('about', { path: '/about-me' });
this.route('badgeTitle', { path: '/badge_title' });
this.route('card-badge', { path: '/card-badge' });
this.route('two-factor-authentication', { path: '/two-factor-authentication' });
});

this.route('invited');
Expand Down
5 changes: 5 additions & 0 deletions app/assets/javascripts/discourse/routes/application.js.es6
Expand Up @@ -84,6 +84,11 @@ const ApplicationRoute = Discourse.Route.extend({
this.controllerFor('notActivated').setProperties(props);
},

showTwoFactorAuthenticationCodeVerifier(props) {
showModal('twoFactorAuthenticationCodeVerifier');
this.controllerFor('twoFactorAuthenticationCodeVerifier').setProperties(props);
},

showUploadSelector(composerView) {
showModal('uploadSelector');
this.controllerFor('upload-selector').setProperties({ composerView: composerView });
Expand Down
@@ -0,0 +1,25 @@
import RestrictedUserRoute from "discourse/routes/restricted-user";

export default RestrictedUserRoute.extend({
renderTemplate() {
return this.render({ into: 'user' });
},

// A bit odd, but if we leave to /preferences we need to re-render that outlet
deactivate() {
this._super();
this.render('preferences', { into: 'user', controller: 'preferences' });
},

setupController(controller, model) {
controller.set('model', model);

if (!model.get('enabledTwoFactorAuthentication')) {
Discourse.ajax(model.get('path') + '/preferences/two_factor_authentication/provisioning_url.json').then(function(result) {
controller.set('twoFactorAuthenticationData', result.otp);
});
}
}

});

2 changes: 1 addition & 1 deletion app/assets/javascripts/discourse/routes/preferences.js.es6
Expand Up @@ -40,7 +40,7 @@ export default RestrictedUserRoute.extend(ShowFooter, {
controller.setProperties(props);
},

saveAvatarSelection: function() {
saveAvatarSelection() {
const user = this.modelFor('user');
const avatarSelector = this.controllerFor('avatar-selector');

Expand Down
@@ -0,0 +1,28 @@
<div class="modal-body">
<form id='two-factor-authentication-form' method='post'>
<div>
<table>
<tr>
<td>
<label for='two-factor-authentication-code'>{{i18n 'login.code'}}&nbsp;</label>
</td>
<td>
{{text-field value=twoFactorAuthenticationCode placeholderKey="login.code_placeholder" id="two-factor-authentication-code" autocorrect="off" autocapitalize="off" autofocus="autofocus"}}
</td>
</tr>
</table>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-large btn-primary"
{{action "verify"}}>
<i class="fa fa-unlock"></i>&nbsp;{{loginButtonText}}
</button>

{{#if authenticate}}
&nbsp; {{i18n 'login.authenticating'}}
{{/if}}

{{loading-spinner condition=showSpinner size="small"}}
</div>
@@ -0,0 +1,76 @@
<section class='user-content'>
<form class="form-horizontal">

<div class="control-group">
<div class="controls">
<h3>{{i18n 'user.two_factor_authentication.configuration.title'}}</h3>
</div>
</div>

{{#if enabledTwoFactorAuthentication}}
<div class="control-group">
<div class="controls">
<p>{{i18n 'user.two_factor_authentication.configuration.setup'}}</p>
</div>
</div>
<div class="control-group">
<label class="control-label">{{i18n 'user.two_factor_authentication.configuration.revoke_label'}}</label>
<div class="controls">
{{d-button action="revoke" icon="minus-circle" label="user.two_factor_authentication.configuration.revoke" class="btn-primary"}}
</div>
</div>
{{else}}
<div class="control-group">
<label class="control-label">{{i18n 'user.two_factor_authentication.configuration.scan'}}</label>
<div class="controls">
<table class="otp-qr">
{{#each row in qr}}
<tr>
{{#each data in row}}
<td {{bind-attr class="data:black:white"}}></td>
{{/each}}
</tr>
{{/each}}
</table>
</div>
</div>

<div class="control-group">
<div class="controls">
<p>{{i18n 'user.two_factor_authentication.configuration.enter_secret'}}</p>
</div>
</div>

<div class="control-group">
<label class="control-label">{{i18n 'user.two_factor_authentication.configuration.app_name'}}</label>
<div class="controls">
<label>{{appName}}</label>
</div>
</div>

<div class="control-group">
<label class="control-label">{{i18n 'user.two_factor_authentication.configuration.secret'}}</label>
<div class="controls">
<label>{{secret}}</label>
</div>
</div>

<div class="control-group">
<label class="control-label">{{i18n 'user.two_factor_authentication.configuration.verify'}}</label>
<div class="controls">
{{input type="text" value=code class="input-xxlarge"}}
</div>
</div>

<div class="control-group">
<div class="controls">
<button class="btn btn-primary" {{bind-attr disabled=disableSave}} {{action "save"}}>{{savingStatus}}</button>
{{#if saved}}{{i18n 'saved'}}{{/if}}
</div>
</div>

{{/if}}


</form>
</section>
34 changes: 22 additions & 12 deletions app/assets/javascripts/discourse/templates/user/preferences.hbs
Expand Up @@ -69,21 +69,31 @@
{{/if}}

{{#if canChangePassword}}
<div class="control-group pref-password">
<label class="control-label">{{i18n 'user.password.title'}}</label>
<div class="controls">
<a href="#" {{action "changePassword"}} class='btn'><i class="fa fa-envelope"></i>
{{#if no_password}}
{{i18n 'user.change_password.set_password'}}
{{else}}
{{i18n 'user.change_password.action'}}
{{/if}}
</a>
{{passwordProgress}}
<div class="control-group pref-password">
<label class="control-label">{{i18n 'user.password.title'}}</label>
<div class="controls">
<a href="#" {{action "changePassword"}} class='btn'><i class="fa fa-envelope"></i>
{{#if no_password}}
{{i18n 'user.change_password.set_password'}}
{{else}}
{{i18n 'user.change_password.action'}}
{{/if}}
</a>
{{passwordProgress}}
</div>
</div>
</div>

{{#if canChangeTwoFactorAuthentication}}
<div class="control-group pref-2fa-auth">
<label class="control-label">{{i18n 'user.two_factor_authentication.title'}}</label>
<div class="controls">
{{#link-to "preferences.two-factor-authentication" class="btn btn-small pad-left no-text"}}{{fa-icon "pencil"}}{{/link-to}}
</div>
</div>
{{/if}}
{{/if}}


<div class="control-group pref-avatar">
<label class="control-label">{{i18n 'user.avatar.title'}}</label>
<div class="controls">
Expand Down

0 comments on commit 08cec58

Please sign in to comment.