FEATURE: Webauthn authenticator management with 2FA login (Security Keys) (#8099)

Adds 2 factor authentication method via second factor security keys over [web authn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).

Allows a user to authenticate a second factor on login, login-via-email, admin-login, and change password routes. Adds registration area within existing user second factor preferences to register multiple security keys. Supports both external (yubikey) and built-in (macOS/android fingerprint readers).
This commit is contained in:
Martin Brennan 2019-10-02 12:08:41 +10:00 committed by Jeff Wong
parent 45ff119f27
commit 68d35b14f4
50 changed files with 2041 additions and 161 deletions

View File

@ -114,6 +114,8 @@ gem 'execjs', require: false
gem 'mini_racer'
gem 'highline', '~> 1.7.0', require: false
gem 'rack-protection' # security
gem 'cbor', require: false
gem 'cose', require: false
# Gems used only for assets and not required in production environments by default.
# Allow everywhere for now cause we are allowing asset debugging in production

View File

@ -77,12 +77,15 @@ GEM
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
byebug (11.0.1)
cbor (0.5.9.6)
certified (1.0.0)
chunky_png (1.3.11)
coderay (1.1.2)
colored2 (3.1.2)
concurrent-ruby (1.1.5)
connection_pool (2.2.2)
cose (0.9.0)
cbor (~> 0.5.9)
cppjieba_rb (0.3.3)
crack (0.4.3)
safe_yaml (~> 1.0.0)
@ -438,8 +441,10 @@ DEPENDENCIES
bootsnap
bullet
byebug
cbor
certified
colored2
cose
cppjieba_rb
css_parser
diffy

View File

@ -0,0 +1,46 @@
import { getWebauthnCredential } from "discourse/lib/webauthn";
export default function() {
document.getElementById(
"activate-security-key-alternative"
).onclick = function() {
document.getElementById("second-factor-forms").style.display = "block";
document.getElementById("primary-security-key-form").style.display = "none";
};
document.getElementById("submit-security-key").onclick = function(e) {
e.preventDefault();
getWebauthnCredential(
document.getElementById("security-key-challenge").value,
document
.getElementById("security-key-allowed-credential-ids")
.value.split(","),
credentialData => {
document.getElementById(
"security-key-credential"
).value = JSON.stringify(credentialData);
e.target.parentElement.submit();
},
errorMessage => {
document.getElementById("security-key-error").innerText = errorMessage;
}
);
};
var useTotp = I18n.t("login.second_factor_toggle.totp");
var useBackup = I18n.t("login.second_factor_toggle.backup_code");
var backupForm = document.getElementById("backup-second-factor-form");
var primaryForm = document.getElementById("primary-second-factor-form");
document.getElementById("toggle-form").onclick = function(event) {
event.preventDefault();
if (backupForm.style.display === "none") {
backupForm.style.display = "block";
primaryForm.style.display = "none";
document.getElementById("toggle-form").innerHTML = useTotp;
} else {
backupForm.style.display = "none";
primaryForm.style.display = "block";
document.getElementById("toggle-form").innerHTML = useBackup;
}
};
}

View File

@ -0,0 +1 @@
require("admin-login/admin-login").default();

View File

@ -4,16 +4,26 @@ import { SECOND_FACTOR_METHODS } from "discourse/models/user";
export default Ember.Component.extend({
@computed("secondFactorMethod")
secondFactorTitle(secondFactorMethod) {
return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
? I18n.t("login.second_factor_title")
: I18n.t("login.second_factor_backup_title");
switch (secondFactorMethod) {
case SECOND_FACTOR_METHODS.TOTP:
return I18n.t("login.second_factor_title");
case SECOND_FACTOR_METHODS.SECURITY_KEY:
return I18n.t("login.second_factor_title");
case SECOND_FACTOR_METHODS.BACKUP_CODE:
return I18n.t("login.second_factor_backup_title");
}
},
@computed("secondFactorMethod")
secondFactorDescription(secondFactorMethod) {
return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
? I18n.t("login.second_factor_description")
: I18n.t("login.second_factor_backup_description");
switch (secondFactorMethod) {
case SECOND_FACTOR_METHODS.TOTP:
return I18n.t("login.second_factor_description");
case SECOND_FACTOR_METHODS.SECURITY_KEY:
return I18n.t("login.security_key_description");
case SECOND_FACTOR_METHODS.BACKUP_CODE:
return I18n.t("login.second_factor_backup_description");
}
},
@computed("secondFactorMethod", "isLogin")
@ -29,6 +39,13 @@ export default Ember.Component.extend({
}
},
@computed("backupEnabled", "secondFactorMethod")
showToggleMethodLink(backupEnabled, secondFactorMethod) {
return (
backupEnabled && secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY
);
},
actions: {
toggleSecondFactorMethod() {
const secondFactorMethod = this.secondFactorMethod;

View File

@ -0,0 +1,11 @@
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
export default Ember.Component.extend({
actions: {
useAnotherMethod() {
this.set("showSecurityKey", false);
this.set("showSecondFactor", true);
this.set("secondFactorMethod", SECOND_FACTOR_METHODS.TOTP);
}
}
});

View File

@ -1,20 +1,40 @@
import computed from "ember-addons/ember-computed-decorators";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import { ajax } from "discourse/lib/ajax";
import DiscourseURL from "discourse/lib/url";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { getWebauthnCredential } from "discourse/lib/webauthn";
export default Ember.Controller.extend({
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
lockImageUrl: Discourse.getURL("/images/lock.svg"),
@computed("model")
secondFactorRequired(model) {
return model.security_key_required || model.second_factor_required;
},
@computed("model")
secondFactorMethod(model) {
return model.security_key_required
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP;
},
actions: {
finishLogin() {
let data = {};
if (this.securityKeyCredential) {
data = { security_key_credential: this.securityKeyCredential };
} else {
data = {
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod
};
}
ajax({
url: `/session/email-login/${this.model.token}`,
type: "POST",
data: {
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod
}
data: data
})
.then(result => {
if (result.success) {
@ -24,6 +44,19 @@ export default Ember.Controller.extend({
}
})
.catch(popupAjaxError);
},
authenticateSecurityKey() {
getWebauthnCredential(
this.model.challenge,
this.model.allowed_credential_ids,
credentialData => {
this.set("securityKeyCredential", credentialData);
this.send("finishLogin");
},
errorMessage => {
this.set("model.error", errorMessage);
}
);
}
}
});

View File

@ -8,6 +8,7 @@ import { escapeExpression, areCookiesEnabled } from "discourse/lib/utilities";
import { extractError } from "discourse/lib/ajax-error";
import computed from "ember-addons/ember-computed-decorators";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import { getWebauthnCredential } from "discourse/lib/webauthn";
// This is happening outside of the app via popup
const AuthErrors = [
@ -43,19 +44,20 @@ export default Ember.Controller.extend(ModalFunctionality, {
loggedIn: false,
secondFactorRequired: false,
showSecondFactor: false,
showSecurityKey: false,
showLoginButtons: true,
awaitingApproval: false
});
},
@computed("showSecondFactor")
credentialsClass(showSecondFactor) {
return showSecondFactor ? "hidden" : "";
@computed("showSecondFactor", "showSecurityKey")
credentialsClass(showSecondFactor, showSecurityKey) {
return showSecondFactor || showSecurityKey ? "hidden" : "";
},
@computed("showSecondFactor")
secondFactorClass(showSecondFactor) {
return showSecondFactor ? "" : "hidden";
@computed("showSecondFactor", "showSecurityKey")
secondFactorClass(showSecondFactor, showSecurityKey) {
return showSecondFactor || showSecurityKey ? "" : "hidden";
},
@computed("awaitingApproval", "hasAtLeastOneLoginButton")
@ -66,6 +68,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
return classes.join(" ");
},
@computed("showSecondFactor", "showSecurityKey")
disableLoginFields(showSecondFactor, showSecurityKey) {
return showSecondFactor || showSecurityKey;
},
@computed("canLoginLocalWithEmail")
hasAtLeastOneLoginButton(canLoginLocalWithEmail) {
return findAll().length > 0 || canLoginLocalWithEmail;
@ -109,15 +116,20 @@ export default Ember.Controller.extend(ModalFunctionality, {
login: this.loginName,
password: this.loginPassword,
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod
second_factor_method: this.secondFactorMethod,
security_key_credential: this.securityKeyCredential
}
}).then(
result => {
// Successful login
if (result && result.error) {
this.set("loggingIn", false);
const invalidSecurityKey = result.reason === "invalid_security_key";
const invalidSecondFactor =
result.reason === "invalid_second_factor";
if (
result.reason === "invalid_second_factor" &&
(invalidSecondFactor || invalidSecurityKey) &&
!this.secondFactorRequired
) {
document.getElementById("modal-alert").style.display = "none";
@ -126,15 +138,24 @@ export default Ember.Controller.extend(ModalFunctionality, {
secondFactorRequired: true,
showLoginButtons: false,
backupEnabled: result.backup_enabled,
showSecondFactor: true
showSecondFactor: invalidSecondFactor,
showSecurityKey: invalidSecurityKey,
secondFactorMethod: invalidSecurityKey
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP,
securityKeyChallenge: result.challenge,
securityKeyAllowedCredentialIds: result.allowed_credential_ids
});
Ember.run.schedule("afterRender", () =>
document
.getElementById("second-factor")
.querySelector("input")
.focus()
);
// only need to focus the 2FA input for TOTP
if (!this.showSecurityKey) {
Ember.run.scheduleOnce("afterRender", () =>
document
.getElementById("second-factor")
.querySelector("input")
.focus()
);
}
return;
} else if (result.reason === "not_activated") {
@ -286,6 +307,20 @@ export default Ember.Controller.extend(ModalFunctionality, {
})
.catch(e => this.flash(extractError(e), "error"))
.finally(() => this.set("processingEmailLink", false));
},
authenticateSecurityKey() {
getWebauthnCredential(
this.securityKeyChallenge,
this.securityKeyAllowedCredentialIds,
credentialData => {
this.set("securityKeyCredential", credentialData);
this.send("login");
},
errorMessage => {
this.flash(errorMessage, "error");
}
);
}
},

View File

@ -4,13 +4,21 @@ import { ajax } from "discourse/lib/ajax";
import PasswordValidation from "discourse/mixins/password-validation";
import { userPath } from "discourse/lib/url";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import { getWebauthnCredential } from "discourse/lib/webauthn";
export default Ember.Controller.extend(PasswordValidation, {
isDeveloper: Ember.computed.alias("model.is_developer"),
admin: Ember.computed.alias("model.admin"),
secondFactorRequired: Ember.computed.alias("model.second_factor_required"),
securityKeyRequired: Ember.computed.alias("model.security_key_required"),
backupEnabled: Ember.computed.alias("model.backup_enabled"),
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
securityKeyOrSecondFactorRequired: Ember.computed.or(
"model.second_factor_required",
"model.security_key_required"
),
secondFactorMethod: Ember.computed.alias("model.security_key_required")
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP,
passwordRequired: true,
errorMessage: null,
successMessage: null,
@ -39,7 +47,8 @@ export default Ember.Controller.extend(PasswordValidation, {
data: {
password: this.accountPassword,
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod
second_factor_method: this.secondFactorMethod,
security_key_credential: this.securityKeyCredential
}
})
.then(result => {
@ -53,15 +62,17 @@ export default Ember.Controller.extend(PasswordValidation, {
DiscourseURL.redirectTo(result.redirect_to || "/");
}
} else {
if (result.errors && result.errors.user_second_factors) {
if (result.errors && !result.errors.password) {
this.setProperties({
secondFactorRequired: true,
secondFactorRequired: this.secondFactorRequired,
securityKeyRequired: this.securityKeyRequired,
password: null,
errorMessage: result.message
});
} else if (this.secondFactorRequired) {
} else if (this.secondFactorRequired || this.securityKeyRequired) {
this.setProperties({
secondFactorRequired: false,
securityKeyRequired: false,
errorMessage: null
});
} else if (
@ -90,6 +101,24 @@ export default Ember.Controller.extend(PasswordValidation, {
});
},
authenticateSecurityKey() {
getWebauthnCredential(
this.model.challenge,
this.model.allowed_credential_ids,
credentialData => {
this.set("securityKeyCredential", credentialData);
this.send("submit");
},
errorMessage => {
this.setProperties({
securityKeyRequired: true,
password: null,
errorMessage: errorMessage
});
}
);
},
done() {
this.set("redirected", true);
DiscourseURL.redirectTo(this.redirectTo || "/");

View File

@ -68,12 +68,14 @@ export default Ember.Controller.extend(CanCheckEmails, {
errorMessage: null,
loaded: true,
totps: response.totps,
security_keys: response.security_keys,
password: null,
dirty: false
});
this.set(
"model.second_factor_enabled",
response.totps && response.totps.length > 0
(response.totps && response.totps.length > 0) ||
(response.security_keys && response.security_keys.length > 0)
);
})
.catch(e => this.handleError(e))
@ -147,6 +149,31 @@ export default Ember.Controller.extend(CanCheckEmails, {
});
},
createSecurityKey() {
const controller = showModal("second-factor-add-security-key", {
model: this.model,
title: "user.second_factor.security_key.add"
});
controller.setProperties({
onClose: () => this.loadSecondFactors(),
markDirty: () => this.markDirty(),
onError: e => this.handleError(e)
});
},
editSecurityKey(security_key) {
const controller = showModal("second-factor-edit-security-key", {
model: security_key,
title: "user.second_factor.security_key.edit"
});
controller.setProperties({
user: this.model,
onClose: () => this.loadSecondFactors(),
markDirty: () => this.markDirty(),
onError: e => this.handleError(e)
});
},
editSecondFactor(second_factor) {
const controller = showModal("second-factor-edit", {
model: second_factor,

View File

@ -0,0 +1,123 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { bufferToBase64, stringToBuffer } from "discourse/lib/webauthn";
// model for this controller is user.js.es6
export default Ember.Controller.extend(ModalFunctionality, {
loading: false,
errorMessage: null,
onShow() {
// clear properties every time because the controller is a singleton
this.setProperties({
errorMessage: null,
loading: true,
securityKeyName: I18n.t("user.second_factor.security_key.default_name")
});
this.model
.requestSecurityKeyChallenge()
.then(response => {
if (response.error) {
this.set("errorMessage", response.error);
return;
}
this.setProperties({
errorMessage: null,
loading: false,
challenge: response.challenge,
relayingParty: {
id: response.rp_id,
name: response.rp_name
},
supported_algoriths: response.supported_algoriths,
user_secure_id: response.user_secure_id,
existing_active_credential_ids:
response.existing_active_credential_ids
});
})
.catch(error => {
this.send("closeModal");
this.onError(error);
})
.finally(() => this.set("loading", false));
},
actions: {
registerSecurityKey() {
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(this.challenge, c => c.charCodeAt(0)),
rp: {
name: this.relayingParty.name,
id: this.relayingParty.id
},
user: {
id: Uint8Array.from(this.user_secure_id, c => c.charCodeAt(0)),
displayName: this.model.username_lower,
name: this.model.username_lower
},
pubKeyCredParams: this.supported_algoriths.map(alg => {
return { type: "public-key", alg: alg };
}),
excludeCredentials: this.existing_active_credential_ids.map(
credentialId => {
return {
type: "public-key",
id: stringToBuffer(atob(credentialId))
};
}
),
timeout: 20000,
attestation: "none",
authenticatorSelection: {
// see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why
// default value of preferred is not necesarrily what we want, it limits webauthn to only devices that support
// user verification, which usually requires entering a PIN
userVerification: "discouraged"
}
};
navigator.credentials
.create({
publicKey: publicKeyCredentialCreationOptions
})
.then(
credential => {
let serverData = {
id: credential.id,
rawId: bufferToBase64(credential.rawId),
type: credential.type,
attestation: bufferToBase64(
credential.response.attestationObject
),
clientData: bufferToBase64(credential.response.clientDataJSON),
name: this.securityKeyName
};
this.model
.registerSecurityKey(serverData)
.then(response => {
if (response.error) {
this.set("errorMessage", response.error);
return;
}
this.markDirty();
this.set("errorMessage", null);
this.send("closeModal");
})
.catch(error => this.onError(error))
.finally(() => this.set("loading", false));
},
err => {
if (err.name === "NotAllowedError") {
return this.set(
"errorMessage",
I18n.t("user.second_factor.security_key.not_allowed_error")
);
}
this.set("errorMessage", err.message);
}
);
}
}
});

View File

@ -0,0 +1,42 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend(ModalFunctionality, {
actions: {
disableSecurityKey() {
this.user
.updateSecurityKey(this.model.id, this.model.name, true)
.then(response => {
if (response.error) {
return;
}
this.markDirty();
})
.catch(error => {
this.send("closeModal");
this.onError(error);
})
.finally(() => {
this.set("loading", false);
this.send("closeModal");
});
},
editSecurityKey() {
this.user
.updateSecurityKey(this.model.id, this.model.name, false)
.then(response => {
if (response.error) {
return;
}
this.markDirty();
})
.catch(error => {
this.onError(error);
})
.finally(() => {
this.set("loading", false);
this.send("closeModal");
});
}
}
});

View File

@ -0,0 +1,74 @@
export function stringToBuffer(str) {
let buffer = new ArrayBuffer(str.length);
let byteView = new Uint8Array(buffer);
for (let i = 0; i < str.length; i++) {
byteView[i] = str.charCodeAt(i);
}
return buffer;
}
export function bufferToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
export function getWebauthnCredential(
challenge,
allowedCredentialIds,
successCallback,
errorCallback
) {
if (typeof PublicKeyCredential === "undefined") {
return errorCallback(I18n.t("login.security_key_support_missing_error"));
}
let challengeBuffer = stringToBuffer(challenge);
let allowCredentials = allowedCredentialIds.map(credentialId => {
return {
id: stringToBuffer(atob(credentialId)),
type: "public-key"
};
});
navigator.credentials
.get({
publicKey: {
challenge: challengeBuffer,
allowCredentials: allowCredentials,
timeout: 60000,
// see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why
// default value of preferred is not necesarrily what we want, it limits webauthn to only devices that support
// user verification, which usually requires entering a PIN
userVerification: "discouraged"
}
})
.then(credential => {
// 1. if there is a credential, check if the raw ID base64 matches
// any of the allowed credential ids
if (
!allowedCredentialIds.some(
credentialId => bufferToBase64(credential.rawId) === credentialId
)
) {
return errorCallback(
I18n.t("login.security_key_no_matching_credential_error")
);
}
const credentialData = {
signature: bufferToBase64(credential.response.signature),
clientData: bufferToBase64(credential.response.clientDataJSON),
authenticatorData: bufferToBase64(
credential.response.authenticatorData
),
credentialId: bufferToBase64(credential.rawId)
};
successCallback(credentialData);
})
.catch(err => {
if (err.name === "NotAllowedError") {
return errorCallback(I18n.t("login.security_key_not_allowed_error"));
}
errorCallback(err);
});
}

View File

@ -21,7 +21,11 @@ import { defaultHomepage } from "discourse/lib/utilities";
import { userPath } from "discourse/lib/url";
import Category from "discourse/models/category";
export const SECOND_FACTOR_METHODS = { TOTP: 1, BACKUP_CODE: 2 };
export const SECOND_FACTOR_METHODS = {
TOTP: 1,
BACKUP_CODE: 2,
SECURITY_KEY: 3
};
const isForever = dt => moment().diff(dt, "years") < -500;
@ -375,6 +379,19 @@ const User = RestModel.extend({
});
},
requestSecurityKeyChallenge() {
return ajax("/u/create_second_factor_security_key.json", {
type: "POST"
});
},
registerSecurityKey(credential) {
return ajax("/u/register_second_factor_security_key.json", {
data: credential,
type: "POST"
});
},
createSecondFactorTotp() {
return ajax("/u/create_second_factor_totp.json", {
type: "POST"
@ -409,6 +426,17 @@ const User = RestModel.extend({
});
},
updateSecurityKey(id, name, disable) {
return ajax("/u/security_key.json", {
data: {
name,
disable,
id
},
type: "PUT"
});
},
toggleSecondFactor(authToken, authMethod, targetMethod, enable) {
return ajax("/u/second_factor.json", {
data: {

View File

@ -14,6 +14,7 @@ export default RestrictedUserRoute.extend({
setupController(controller, model) {
controller.setProperties({ model, newUsername: model.get("username") });
controller.set("loading", true);
model
.loadSecondFactorCodes("")
.then(response => {
@ -24,7 +25,8 @@ export default RestrictedUserRoute.extend({
errorMessage: null,
loaded: !response.password_required,
dirty: !!response.password_required,
totps: response.totps
totps: response.totps,
security_keys: response.security_keys
});
}
})

View File

@ -5,12 +5,9 @@
{{/if}}
<p>{{secondFactorDescription}}</p>
{{yield}}
{{#if backupEnabled}}
{{#if showToggleMethodLink}}
<p>
{{discourse-linked-text
class="toggle-second-factor-method"
action=(action "toggleSecondFactorMethod")
text=linkText}}
<a href="" class="toggle-second-factor-method" {{action "toggleSecondFactorMethod"}}>{{ i18n linkText }}</a>
</p>
{{/if}}
</div>

View File

@ -0,0 +1,14 @@
<div id="security-key">
{{d-button
action=action
icon="key"
id="security-key-authenticate-button"
label="login.security_key_authenticate"
type="button"
class='btn btn-large btn-primary'}}
<p>
{{#if otherMethodAllowed}}
<a href="" class="toggle-second-factor-method" {{action "useAnotherMethod"}}>{{ i18n 'login.security_key_alternative' }}</a>
{{/if}}
</p>
</div>

View File

@ -12,20 +12,34 @@
{{/if}}
{{#if model.can_login}}
{{#if model.second_factor_required}}
{{#second-factor-form
secondFactorMethod=secondFactorMethod
secondFactorToken=secondFactorToken
backupEnabled=model.backup_codes_enabled
isLogin=true}}
{{second-factor-input value=secondFactorToken secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
{{/second-factor-form}}
{{#if secondFactorRequired }}
{{#if model.security_key_required }}
{{#security-key-form
allowedCredentialIds=model.allowed_credential_ids
challenge=model.security_key_challenge
showSecurityKey=model.security_key_required
showSecondFactor=false
secondFactorMethod=secondFactorMethod
otherMethodAllowed=secondFactorRequired
action=(action "authenticateSecurityKey")}}
{{/security-key-form}}
{{else}}
{{#second-factor-form
secondFactorMethod=secondFactorMethod
secondFactorToken=secondFactorToken
backupEnabled=model.backup_codes_enabled
isLogin=true}}
{{second-factor-input value=secondFactorToken secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
{{/second-factor-form}}
{{/if}}
{{else}}
<h2>{{i18n "email_login.confirm_title" site_name=siteSettings.title}}</h2>
<p>{{i18n "email_login.logging_in_as" email=model.token_email}}</p>
{{/if}}
{{d-button label="email_login.confirm_button" action=(action "finishLogin") class="btn-primary"}}
{{#unless model.security_key_required }}
{{d-button label="email_login.confirm_button" action=(action "finishLogin") class="btn-primary"}}
{{/unless}}
{{/if}}
</form>
</div>

View File

@ -8,11 +8,11 @@
<table>
<tr>
<td><label for='login-account-name'>{{i18n 'login.username'}}</label></td>
<td>{{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus" disabled=showSecondFactor}}</td>
<td>{{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus" disabled=disableLoginFields}}</td>
</tr>
<tr>
<td><label for='login-account-password'>{{i18n 'login.password'}}</label></td>
<td>{{password-field value=loginPassword type="password" id="login-account-password" maxlength="200" capsLockOn=capsLockOn disabled=showSecondFactor}}</td>
<td>{{password-field value=loginPassword type="password" id="login-account-password" maxlength="200" capsLockOn=capsLockOn disabled=disableLoginFields}}</td>
<td><a id="forgot-password-link" {{action "forgotPassword"}}>{{i18n 'forgot_password.action'}}</a></td>
</tr>
<tr>
@ -28,7 +28,19 @@
class=secondFactorClass
backupEnabled=backupEnabled
isLogin=true}}
{{second-factor-input value=secondFactorToken inputId='login-second-factor' secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
{{#if showSecurityKey}}
{{#security-key-form
allowedCredentialIds=securityKeyAllowedCredentialIds
challenge=securityKeyChallenge
showSecurityKey=showSecurityKey
showSecondFactor=showSecondFactor
secondFactorMethod=secondFactorMethod
otherMethodAllowed=secondFactorRequired
action=(action "authenticateSecurityKey")}}
{{/security-key-form}}
{{else}}
{{second-factor-input value=secondFactorToken inputId='login-second-factor' secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
{{/if}}
{{/second-factor-form}}
</form>
{{/if}}
@ -43,13 +55,16 @@
<div class="modal-footer">
{{#if canLoginLocal}}
{{d-button
action=(action "login")
form="login-form"
icon="unlock"
label=loginButtonLabel
disabled=loginDisabled
class='btn btn-large btn-primary'}}
{{#unless showSecurityKey }}
{{d-button
action=(action "login")
id="login-button"
form="login-form"
icon="unlock"
label=loginButtonLabel
disabled=loginDisabled
class='btn btn-large btn-primary'}}
{{/unless}}
{{#if showSignupLink}}
<button class="btn btn-large" id="new-account-link" {{action "createAccount"}}>

View File

@ -0,0 +1,31 @@
{{#d-modal-body}}
{{#conditional-loading-spinner condition=loading}}
{{#if errorMessage}}
<div class="control-group">
<div class="controls">
<div class='alert alert-error'>{{errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group">
<div class="controls">
{{{i18n 'user.second_factor.enable_security_key_description'}}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{input value=securityKeyName id='test' placeholder='security key name'}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action=(action "registerSecurityKey")
class="btn btn-primary add-totp"
label="user.second_factor.security_key.register"}}
</div>
</div>
{{/conditional-loading-spinner}}
{{/d-modal-body}}

View File

@ -0,0 +1,15 @@
{{#d-modal-body}}
<div class="form-horizontal">
{{input type="text" value=model.name}}
</div>
<div class='second-factor instructions'>
{{i18n 'user.second_factor.security_key.edit_description'}}
</div>
{{d-button action=(action "editSecurityKey")
class="btn-primary"
label="user.second_factor.security_key.edit"}}
{{d-button action=(action "disableSecurityKey")
class="btn-danger"
label="user.second_factor.security_key.disable"}}
{{/d-modal-body}}

View File

@ -16,20 +16,33 @@
{{/if}}
{{else}}
<form>
{{#if secondFactorRequired}}
{{#if securityKeyOrSecondFactorRequired }}
{{#if errorMessage}}
<div class='alert alert-error'>{{errorMessage}}</div>
<br/>
{{/if}}
{{#second-factor-form
secondFactorMethod=secondFactorMethod
secondFactorToken=secondFactorToken
backupEnabled=backupEnabled
isLogin=false}}
{{second-factor-input value=secondFactorToken inputId='second-factor' secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
{{/second-factor-form}}
{{d-button action=(action "submit") class='btn-primary' label='submit'}}
{{#if securityKeyRequired }}
{{#security-key-form
allowedCredentialIds=model.allowed_credential_ids
challenge=model.security_key_challenge
showSecurityKey=model.security_key_required
showSecondFactor=false
secondFactorMethod=secondFactorMethod
otherMethodAllowed=secondFactorRequired
action=(action "authenticateSecurityKey")}}
{{/security-key-form}}
{{else}}
{{#second-factor-form
secondFactorMethod=secondFactorMethod
secondFactorToken=secondFactorToken
backupEnabled=backupEnabled
isLogin=false}}
{{second-factor-input value=secondFactorToken inputId='second-factor' secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
{{/second-factor-form}}
{{/if}}
{{#unless securityKeyRequired }}
{{d-button action=(action "submit") class='btn-primary' label='submit'}}
{{/unless}}
{{else}}
<h2>{{i18n 'user.change_password.choose'}}</h2>

View File

@ -54,6 +54,33 @@
</div>
</div>
<div class="control-group">
<div class="controls">
<h2>{{i18n "user.second_factor.security_key.title"}}</h2>
{{d-button action=(action "createSecurityKey")
class="btn-primary new-security-key"
disabled=loading
label="user.second_factor.security_key.add"}}
{{#each security_keys as |security_key|}}
<div class="second-factor-item">
{{#if security_key.name}}
{{security_key.name}}
{{else}}
{{i18n "user.second_factor.security_key.default_name"}}
{{/if}}
{{#if isCurrentUser}}
{{d-button action=(action "editSecurityKey" security_key)
class="btn-default btn-small btn-icon pad-left no-text edit"
disabled=loading
icon="pencil-alt"
}}
{{/if}}
</div>
{{/each}}
</div>
</div>
<div class="control-group">
<div class="controls pref-second-factor-backup">
<h2>{{i18n "user.second_factor_backup.title"}}</h2>

View File

@ -33,10 +33,11 @@
form {
min-width: 300px;
max-width: 100%;
}
#modal-alert {
max-width: 500px;
max-width: 100%;
padding: s(2 4);
}

View File

@ -4,6 +4,7 @@ require_dependency 'rate_limiter'
require_dependency 'single_sign_on'
require_dependency 'single_sign_on_provider'
require_dependency 'url_helper'
require_dependency 'webauthn/webauthn'
class SessionController < ApplicationController
class LocalLoginNotAllowed < StandardError; end
@ -298,7 +299,18 @@ class SessionController < ApplicationController
if payload = login_error_check(user)
render json: payload
else
if user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
if (params[:second_factor_token].blank?)
security_key_valid = ::Webauthn::SecurityKeyAuthenticationService.new(user, params[:security_key_credential],
challenge: secure_session["staged-webauthn-challenge-#{user.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{user.id}"],
origin: Discourse.base_url
).authenticate_security_key
return invalid_security_key(user) if user.security_keys_enabled? && !security_key_valid
end
if user.totp_enabled? && \
!user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i) &&
!params[:security_key_credential].present?
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code"),
reason: "invalid_second_factor",
@ -308,6 +320,17 @@ class SessionController < ApplicationController
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
end
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key(user, err.message)
end
def invalid_security_key(user, err_message = nil)
stage_webauthn_security_key_challenge(user) if !params[:security_key_credential]
return render json: failed_json.merge(
error: err_message || I18n.t("login.invalid_security_key"),
reason: "invalid_security_key",
backup_enabled: user.backup_codes_enabled?
).merge(webauthn_security_key_challenge_and_allowed_credentials(user))
end
def email_login_info
@ -323,10 +346,18 @@ class SessionController < ApplicationController
token_email: matched_token.email
}
if matched_token.user&.totp_enabled?
matched_user = matched_token.user
if matched_user&.totp_enabled?
response.merge!(
second_factor_required: true,
backup_codes_enabled: matched_token.user&.backup_codes_enabled?
backup_codes_enabled: matched_user&.backup_codes_enabled?
)
end
if matched_user&.security_keys_enabled?
stage_webauthn_security_key_challenge(matched_user)
response.merge!(
webauthn_security_key_challenge_and_allowed_credentials(matched_user).merge(security_key_required: true)
)
end
@ -343,15 +374,27 @@ class SessionController < ApplicationController
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
token = params[:token]
matched_token = EmailToken.confirmable(token)
if matched_token&.user&.totp_enabled?
if !second_factor_token.present?
return render json: { error: I18n.t('login.invalid_second_factor_code') }
elsif !matched_token.user.authenticate_second_factor(second_factor_token, second_factor_method)
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
return render json: { error: I18n.t('login.invalid_second_factor_code') }
if security_key_credential.present?
if matched_token&.user&.security_keys_enabled?
security_key_valid = ::Webauthn::SecurityKeyAuthenticationService.new(matched_token&.user, params[:security_key_credential],
challenge: secure_session["staged-webauthn-challenge-#{matched_token&.user&.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{matched_token&.user&.id}"],
origin: Discourse.base_url
).authenticate_security_key
return invalid_security_key(matched_token&.user) if !security_key_valid
end
else
if matched_token&.user&.totp_enabled?
if !second_factor_token.present?
return render json: { error: I18n.t('login.invalid_second_factor_code') }
elsif !matched_token.user.authenticate_second_factor(second_factor_token, second_factor_method)
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
return render json: { error: I18n.t('login.invalid_second_factor_code') }
end
end
end
@ -367,6 +410,8 @@ class SessionController < ApplicationController
end
return render json: { error: I18n.t('email_login.invalid_token') }
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key(user, err.message)
end
def one_time_password
@ -535,4 +580,21 @@ class SessionController < ApplicationController
def sso_url(sso)
sso.to_url
end
def stage_webauthn_security_key_challenge(user)
challenge = SecureRandom.hex(30)
secure_session["staged-webauthn-challenge-#{user.id}"] = challenge
secure_session["staged-webauthn-rp-id-#{user.id}"] = Discourse.current_hostname
end
def webauthn_security_key_challenge_and_allowed_credentials(user)
return {} if !user.security_keys_enabled?
credential_ids = user.security_keys.select(:credential_id)
.where(factor_type: UserSecurityKey.factor_types[:second_factor])
.pluck(:credential_id)
{
allowed_credential_ids: credential_ids,
challenge: secure_session["staged-webauthn-challenge-#{user.id}"]
}
end
end

View File

@ -6,6 +6,7 @@ require_dependency 'rate_limiter'
require_dependency 'wizard'
require_dependency 'wizard/builder'
require_dependency 'admin_confirmation'
require_dependency 'webauthn/webauthn'
class UsersController < ApplicationController
@ -17,7 +18,8 @@ class UsersController < ApplicationController
:topic_tracking_state, :preferences, :create_second_factor_totp,
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
:update_second_factor, :create_second_factor_backup, :select_avatar,
:notification_level, :revoke_auth_token
:notification_level, :revoke_auth_token, :register_second_factor_security_key,
:create_second_factor_security_key
]
skip_before_action :check_xhr, only: [
@ -28,7 +30,9 @@ class UsersController < ApplicationController
before_action :second_factor_check_confirmed_password, only: [
:create_second_factor_totp, :enable_second_factor_totp,
:disable_second_factor, :update_second_factor, :create_second_factor_backup]
:disable_second_factor, :update_second_factor, :create_second_factor_backup,
:register_second_factor_security_key, :create_second_factor_security_key
]
before_action :respond_to_suspicious_request, only: [:create]
@ -496,17 +500,33 @@ class UsersController < ApplicationController
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
if second_factor_token.present? && UserSecondFactor.methods[second_factor_method]
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
second_factor_authenticated = @user&.authenticate_second_factor(second_factor_token, second_factor_method)
elsif security_key_credential.present?
security_key_authenticated = ::Webauthn::SecurityKeyAuthenticationService.new(
@user,
security_key_credential,
challenge: secure_session["staged-webauthn-challenge-#{@user.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{@user.id}"],
origin: Discourse.base_url
).authenticate_security_key
end
if second_factor_authenticated || !@user&.totp_enabled?
second_factor_totp_disabled = !@user&.totp_enabled?
if second_factor_authenticated || second_factor_totp_disabled || security_key_authenticated
secure_session["second-factor-#{token}"] = "true"
end
security_key_disabled = !@user&.security_keys_enabled?
if security_key_authenticated || security_key_disabled
secure_session["security-key-#{token}"] = "true"
end
valid_second_factor = secure_session["second-factor-#{token}"] == "true"
valid_security_key = secure_session["security-key-#{token}"] == "true"
if !@user
@error = I18n.t('password_reset.no_token')
@ -539,13 +559,17 @@ class UsersController < ApplicationController
if @error
render layout: 'no_ember'
else
stage_webauthn_security_key_challenge(@user)
store_preloaded(
"password_reset",
MultiJson.dump(
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
backup_enabled: @user.backup_codes_enabled?
{
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
security_key_required: !valid_security_key,
backup_enabled: @user.backup_codes_enabled?
}.merge(webauthn_security_key_challenge_and_allowed_credentials(@user))
)
)
end
@ -578,18 +602,25 @@ class UsersController < ApplicationController
errors: @user&.errors&.to_hash
}
else
stage_webauthn_security_key_challenge(@user) if !valid_security_key && !security_key_credential.present?
render json: {
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
security_key_required: !valid_security_key,
backup_enabled: @user.backup_codes_enabled?
}
}.merge(webauthn_security_key_challenge_and_allowed_credentials(@user))
end
end
end
end
rescue RateLimiter::LimitExceeded => e
render_rate_limit_error(e)
rescue ::Webauthn::SecurityKeyError => err
render json: {
message: err.message,
errors: [err.message]
}
end
def confirm_email_token
@ -636,27 +667,55 @@ class UsersController < ApplicationController
email_token_user = EmailToken.confirmable(token)&.user
totp_enabled = email_token_user&.totp_enabled?
security_keys_enabled = email_token_user&.security_keys_enabled?
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
confirm_email = false
@security_key_required = security_keys_enabled
confirm_email =
if security_keys_enabled && params[:security_key_credential].blank?
stage_webauthn_security_key_challenge(email_token_user)
challenge_and_credentials = webauthn_security_key_challenge_and_allowed_credentials(email_token_user)
@security_key_challenge = challenge_and_credentials[:challenge]
@security_key_allowed_credential_ids = challenge_and_credentials[:allowed_credential_ids].join(",")
end
if security_keys_enabled && params[:security_key_credential].present?
credential = JSON.parse(params[:security_key_credential]).with_indifferent_access
confirm_email = ::Webauthn::SecurityKeyAuthenticationService.new(email_token_user, credential,
challenge: secure_session["staged-webauthn-challenge-#{email_token_user&.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{email_token_user&.id}"],
origin: Discourse.base_url
).authenticate_security_key
@message = I18n.t('login.security_key_invalid') if !confirm_email
elsif security_keys_enabled && second_factor_token.blank?
confirm_email = false
@message = I18n.t("login.second_factor_title")
if totp_enabled
@second_factor_required = true
@backup_codes_enabled = true
@message = I18n.t("login.second_factor_title")
if second_factor_token.present?
if email_token_user.authenticate_second_factor(second_factor_token, second_factor_method)
true
else
@error = I18n.t("login.invalid_second_factor_code")
false
end
end
else
true
end
else
confirm_email =
if totp_enabled
@second_factor_required = true
@backup_codes_enabled = true
@message = I18n.t("login.second_factor_title")
if second_factor_token.present?
if email_token_user.authenticate_second_factor(second_factor_token, second_factor_method)
true
else
@error = I18n.t("login.invalid_second_factor_code")
false
end
end
else
true
end
end
if confirm_email
@user = EmailToken.confirm(token)
@ -673,10 +732,13 @@ class UsersController < ApplicationController
end
end
render layout: false
render layout: 'no_ember'
rescue RateLimiter::LimitExceeded
@message = I18n.t("rate_limiter.slow_down")
render layout: false
render layout: 'no_ember'
rescue ::Webauthn::SecurityKeyError => err
@message = err.message
render layout: 'no_ember'
end
def email_login
@ -1110,8 +1172,15 @@ class UsersController < ApplicationController
end
if secure_session["confirmed-password-#{current_user.id}"] == "true"
totp_second_factors = current_user.totps
.select(:id, :name, :last_used, :created_at, :method)
.where(enabled: true).order(:created_at)
security_keys = current_user.security_keys.where(factor_type: UserSecurityKey.factor_types[:second_factor]).order(:created_at)
render json: success_json.merge(
totps: current_user.totps.select(:id, :name, :last_used, :created_at, :method).order(:created_at)
totps: totp_second_factors,
security_keys: security_keys
)
else
render json: success_json.merge(
@ -1144,6 +1213,55 @@ class UsersController < ApplicationController
)
end
def create_second_factor_security_key
challenge = SecureRandom.hex(30)
secure_session["staged-webauthn-challenge-#{current_user.id}"] = challenge
secure_session["staged-webauthn-rp-id-#{current_user.id}"] = Discourse.current_hostname
secure_session["staged-webauthn-rp-name-#{current_user.id}"] = SiteSetting.title
render json: success_json.merge(
challenge: challenge,
rp_id: Discourse.current_hostname,
rp_name: SiteSetting.title,
supported_algoriths: ::Webauthn::SUPPORTED_ALGORITHMS,
user_secure_id: current_user.create_or_fetch_secure_identifier,
existing_active_credential_ids: current_user.second_factor_security_key_credential_ids
)
end
def register_second_factor_security_key
params.require(:name)
params.require(:attestation)
params.require(:clientData)
::Webauthn::SecurityKeyRegistrationService.new(
current_user,
params,
challenge: secure_session["staged-webauthn-challenge-#{current_user.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{current_user.id}"],
origin: Discourse.base_url
).register_second_factor_security_key
render json: success_json
rescue ::Webauthn::SecurityKeyError => err
render json: failed_json.merge(
error: err.message
)
end
def update_security_key
user_security_key = current_user.security_keys.find_by(id: params[:id].to_i)
raise Discourse::InvalidParameters unless user_security_key
if params[:name] && !params[:name].blank?
user_security_key.update!(name: params[:name])
end
if params[:disable] == "true"
user_security_key.update!(enabled: false)
end
render json: success_json
end
def enable_second_factor_totp
params.require(:second_factor_token)
params.require(:name)
@ -1373,4 +1491,19 @@ class UsersController < ApplicationController
end
end
end
def stage_webauthn_security_key_challenge(user)
challenge = SecureRandom.hex(30)
secure_session["staged-webauthn-challenge-#{user.id}"] = challenge
secure_session["staged-webauthn-rp-id-#{user.id}"] = Discourse.current_hostname
end
def webauthn_security_key_challenge_and_allowed_credentials(user)
return {} if !user.security_keys_enabled?
credential_ids = user.second_factor_security_key_credential_ids
{
allowed_credential_ids: credential_ids,
challenge: secure_session["staged-webauthn-challenge-#{user.id}"]
}
end
end

View File

@ -51,6 +51,12 @@ module SecondFactorManager
self&.user_second_factors.backup_codes.exists?
end
def security_keys_enabled?
!SiteSetting.enable_sso &&
SiteSetting.enable_local_logins &&
self&.security_keys.where(factor_type: UserSecurityKey.factor_types[:second_factor], enabled: true).exists?
end
def remaining_backup_codes
self&.user_second_factors&.backup_codes&.count
end

View File

@ -79,6 +79,10 @@ class User < ActiveRecord::Base
where(method: UserSecondFactor.methods[:totp], enabled: true)
}, class_name: "UserSecondFactor"
has_many :security_keys, -> {
where(enabled: true)
}, class_name: "UserSecurityKey"
has_one :anonymous_user_master, class_name: 'AnonymousUser'
has_one :anonymous_user_shadow, ->(record) { where(active: true) }, foreign_key: :master_user_id, class_name: 'AnonymousUser'
@ -1263,6 +1267,20 @@ class User < ActiveRecord::Base
SQL
end
def create_or_fetch_secure_identifier
return secure_identifier if secure_identifier.present?
new_secure_identifier = SecureRandom.hex(20)
self.update(secure_identifier: new_secure_identifier)
new_secure_identifier
end
def second_factor_security_key_credential_ids
security_keys
.select(:credential_id)
.where(factor_type: UserSecurityKey.factor_types[:second_factor])
.pluck(:credential_id)
end
protected
def badge_grant

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
class UserSecurityKey < ActiveRecord::Base
belongs_to :user
scope :second_factors, -> do
where(factor_type: UserSecurityKey.factor_types[:second_factor], enabled: true)
end
def self.factor_types
@factor_types ||= Enum.new(
second_factor: 0,
first_factor: 1,
multi_factor: 2,
)
end
end
# == Schema Information
#
# Table name: user_security_keys
#
# id :bigint not null, primary key
# user_id :integer not null
# factor_type :integer not null
# credential_id :string not null, UNIQUE
# public_key :string not null
# enabled :boolean default(FALSE), not null
# last_used :datetime
# created_at :datetime not null
# updated_at :datetime not null
# name :string not null
#
# Indexes
#
# index_user_security_keys_on_credential_id (credential_id) (UNIQUE)
# index_user_security_keys_on_factor_type (factor_type)
#

View File

@ -1,18 +0,0 @@
<%= javascript_tag do %>
var useTotp = "<%= t("login.second_factor_toggle.totp") %>";
var useBackup = "<%= t("login.second_factor_toggle.backup_code") %>";
var backupForm = document.getElementById("backup-second-factor-form");
var primaryForm = document.getElementById("primary-second-factor-form");
document.getElementById("toggle-form").onclick = function(event) {
event.preventDefault();
if (backupForm.style.display === "none") {
backupForm.style.display = "block";
primaryForm.style.display = "none";
document.getElementById("toggle-form").innerHTML = useTotp;
} else {
backupForm.style.display = "none";
primaryForm.style.display = "block";
document.getElementById("toggle-form").innerHTML = useBackup;
}
}
<% end %>

View File

@ -1,39 +1,61 @@
<html>
<head>
<title>Admin Login</title>
</head>
<body>
<% if @message %>
<%= @message %>
<% if @error %><p><%= @error %></p><% end %>
<% if @message %>
<%= @message %>
<% if @error %><p><%= @error %></p><% end %>
<% if @second_factor_required %>
<div id="primary-second-factor-form">
<%=form_tag({}, method: :put) do %>
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
<%= render 'common/second_factor_text_field' %><br><br>
<%= submit_tag t('submit')%>
<% end %>
</div>
<% if @security_key_required %>
<div id="primary-security-key-form">
<div id="security-key-error"></div>
<%= hidden_field_tag 'security_key_challenge', @security_key_challenge, id: 'security-key-challenge' %>
<%= hidden_field_tag 'security_key_allowed_credential_ids', @security_key_allowed_credential_ids, id: 'security-key-allowed-credential-ids' %>
<%if @backup_codes_enabled%>
<div id="backup-second-factor-form" style="display: none">
<%= form_tag({}, method: :put) do%>
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
<%= render 'common/second_factor_backup_input' %><br><br>
<%= submit_tag(t("submit")) %>
<%end%>
</div>
<a href id="toggle-form"><%=t "login.second_factor_backup" %></a>
<%= render 'common/second_factor_form_script' %>
<%end%>
<% end %>
<% else %>
<%=form_tag({}, method: :put) do %>
<%= label_tag(:email, t('admin_login.email_input')) %>
<%= text_field_tag(:email, nil, autofocus: true) %><br><br>
<%= submit_tag t('admin_login.submit_button') %>
<p><strong><%= t('login.security_key_authenticate') %></strong></p>
<p><%= t('login.security_key_description') %></p>
<%= hidden_field_tag 'second_factor_method', '3' %>
<%= hidden_field_tag 'security_key_credential', nil, id: 'security-key-credential' %>
<% if @second_factor_required %>
<%= link_to t('login.security_key_alternative'), '#', id: 'activate-security-key-alternative' %><br/><br/>
<% end %>
<%= button_tag t('login.security_key_authenticate'), id: 'submit-security-key' %>
<% end %>
<% end %>
</body>
</html>
</div>
<% end %>
<% if @second_factor_required %>
<div id="second-factor-forms" style="<%= @security_key_required ? 'display: none' : '' %>">
<div id="primary-second-factor-form">
<%=form_tag({}, method: :put) do %>
<br/>
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
<%= render 'common/second_factor_text_field' %><br><br>
<%= submit_tag t('submit')%>
<% end %>
</div>
<%if @backup_codes_enabled%>
<div id="backup-second-factor-form" style="display: none">
<%= form_tag({}, method: :put) do%>
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
<%= render 'common/second_factor_backup_input' %><br><br>
<%= submit_tag(t("submit")) %>
<%end%>
</div>
<a href id="toggle-form"><%=t "login.second_factor_backup" %></a>
<%end%>
</div>
<% end %>
<% else %>
<%=form_tag({}, method: :put) do %>
<%= label_tag(:email, t('admin_login.email_input')) %>
<%= text_field_tag(:email, nil, autofocus: true) %><br><br>
<%= submit_tag t('admin_login.submit_button') %>
<% end %>
<% end %>
<%= preload_script "ember_jquery" %>
<%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script "locales/i18n" %>
<%= preload_script "discourse/lib/webauthn" %>
<%= preload_script "admin-login/admin-login" %>
<%= preload_script "admin-login/admin-login.no-module" %>

View File

@ -144,6 +144,10 @@ module Discourse
activate-account.js
auto-redirect.js
wizard-start.js
locales/i18n.js
discourse/lib/webauthn.js
admin-login/admin-login.js
admin-login/admin-login.no-module.js
onpopstate-handler.js
embed-application.js
}

View File

@ -960,7 +960,7 @@ en:
copied_to_clipboard: "Copied to Clipboard"
copy_to_clipboard_error: "Error copying data to Clipboard"
remaining_codes: "You have <strong>{{count}}</strong> backup codes remaining."
use: "<a href>Use a backup code</a>"
use: "Use a backup code"
enable_prerequisites: "You must enable a primary second factor before generating backup codes."
codes:
title: "Backup Codes Generated"
@ -981,7 +981,7 @@ en:
extended_description: |
Two factor authentication adds extra security to your account by requiring a one-time token in addition to your password. Tokens can be generated on <a href="https://www.google.com/search?q=authenticator+apps+for+android" target='_blank'>Android</a> and <a href="https://www.google.com/search?q=authenticator+apps+for+ios">iOS</a> devices.
oauth_enabled_warning: "Please note that social logins will be disabled once two factor authentication has been enabled on your account."
use: "<a href>Use Authenticator app</a>"
use: "Use Authenticator app"
enforced_notice: "You are required to enable two factor authentication before accessing this site."
disable: "disable"
disable_title: "Disable Second Factor"
@ -989,10 +989,20 @@ en:
edit: "Edit"
edit_title: "Edit Second Factor"
edit_description: "Second Factor Name"
enable_security_key_description: "When you have your physical security key prepared press the Register button below."
totp:
title: "Token-Based Authenticators"
add: "New Authenticator"
default_name: "My Authenticator"
security_key:
register: "Register"
title: 'Security Keys'
add: "Register Security Key"
default_name: "Main Security Key"
not_allowed_error: "The security key registration process either timed out or was cancelled."
edit: 'Edit Security Key'
edit_description: 'Security Key Name'
disable: 'Disable'
change_about:
title: "Change About Me"
@ -1440,10 +1450,16 @@ en:
password: "Password"
second_factor_title: "Two Factor Authentication"
second_factor_description: "Please enter the authentication code from your app:"
second_factor_backup: "<a href>Log in using a backup code</a>"
second_factor_backup: "Log in using a backup code"
second_factor_backup_title: "Two Factor Backup"
second_factor_backup_description: "Please enter one of your backup codes:"
second_factor: "<a href>Log in using Authenticator app</a>"
second_factor: "Log in using Authenticator app"
security_key_description: "When you have your physical security key prepared press the Authenticate with Security Key button below."
security_key_alternative: "Can't find your security key or want to use another method?"
security_key_authenticate: "Authenticate with Security Key"
security_key_not_allowed_error: "The security key authentication process either timed out or was cancelled."
security_key_no_matching_credential_error: "No matching credentials could be found in the provided security key."
security_key_support_missing_error: "Your current device or browser does not support the use of security keys. Please use a different method."
email_placeholder: "email or username"
caps_lock_warning: "Caps Lock is on"
error: "Unknown error"
@ -1499,6 +1515,9 @@ en:
name: "Discord"
title: "with Discord"
message: "Authenticating with Discord"
second_factor_toggle:
totp: "Use an authenticator app instead"
backup_code: "Use a backup code instead"
invites:
accept_title: "Invitation"
welcome_to: "Welcome to %{site_name}!"

View File

@ -880,6 +880,21 @@ en:
no_drafts:
self: "You have no drafts; begin composing a reply in any topic and it will be auto-saved as a new draft."
webauthn:
validation:
invalid_type_error: 'The webauthn type provided was invalid. Valid types are webauthn.get and webauthn.create.'
challenge_mismatch_error: 'The provided challenge does not match the challenge generated by the authentication server.'
invalid_origin_error: 'The origin of the authentication request does not match the server origin.'
malformed_attestation_error: 'There was an error decoding the attestation data.'
invalid_relying_party_id_error: 'The Relying Party ID of the authentication request does not match the server Relying Party ID.'
user_verification_error: 'User verification is required.'
unsupported_public_key_algorithm_error: 'The provided public key algorithm is not supported by the server.'
unsupported_attestation_format_error: 'The attestation format is not supported by the server.'
credential_id_in_use_error: 'The credential ID provided is already in use.'
public_key_error: 'The public key verification for the credential failed.'
ownership_error: 'The security key is not owned by the user.'
not_found_error: 'A security key with the provided credential ID could not be found.'
topic_flag_types:
spam:
title: "Spam"
@ -2229,6 +2244,13 @@ en:
auto_deleted_by_timer: "Automatically deleted by timer."
login:
security_key_description: "When you have your physical security key prepared press the Authenticate with Security Key button below."
security_key_alternative: "Can't find your security key or want to use another method?"
security_key_authenticate: "Authenticate with Security Key"
security_key_not_allowed_error: "The security key authentication process either timed out or was cancelled."
security_key_no_matching_credential_error: "No matching credentials could be found in the provided security key."
security_key_support_missing_error: "Your current device or browser does not support the use of security keys. Please use a different method."
security_key_invalid: "There was an error validating the security key."
not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in."
incorrect_username_email_or_password: "Incorrect username, email or password"
incorrect_password: "Incorrect password"
@ -2265,6 +2287,7 @@ en:
second_factor_backup_description: "Please enter one of your backup codes:"
second_factor_backup_title: "Two Factor Backup Code"
invalid_second_factor_code: "Invalid authentication code. Each code can only be used once."
invalid_security_key: "Invalid security key."
second_factor_toggle:
totp: "Use an authenticator app instead"
backup_code: "Use a backup code instead"

View File

@ -379,6 +379,10 @@ Discourse::Application.routes.draw do
post "#{root_path}/second_factors" => "users#list_second_factors"
put "#{root_path}/second_factor" => "users#update_second_factor"
post "#{root_path}/create_second_factor_security_key" => "users#create_second_factor_security_key"
post "#{root_path}/register_second_factor_security_key" => "users#register_second_factor_security_key"
put "#{root_path}/security_key" => "users#update_security_key"
post "#{root_path}/create_second_factor_totp" => "users#create_second_factor_totp"
post "#{root_path}/enable_second_factor_totp" => "users#enable_second_factor_totp"
put "#{root_path}/disable_second_factor" => "users#disable_second_factor"

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class CreateUserSecurityKeys < ActiveRecord::Migration[5.2]
def up
create_table :user_security_keys do |t|
t.references :user, null: false, index: true, foreign_key: true
t.string :credential_id, null: false
t.string :public_key, null: false, index: true
t.integer :factor_type, null: false, default: 0, index: true
t.boolean :enabled, null: false, default: true
t.string :name, null: false
t.datetime :last_used
t.timestamps
end
add_index :user_security_keys, :credential_id, unique: true
add_index :user_security_keys, :last_used
end
def down
if table_exists?(:user_security_keys)
drop_table(:user_security_keys)
end
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddSecureIdentifierColumnToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :secure_identifier, :string
add_index :users, :secure_identifier, unique: true
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddEnabledIndexToUserSecurityKey < ActiveRecord::Migration[6.0]
def change
add_index :user_security_keys, [:factor_type, :enabled]
end
end

View File

@ -0,0 +1,87 @@
# frozen_string_literal: true
require 'cose'
module Webauthn
class SecurityKeyAuthenticationService < SecurityKeyBaseValidationService
##
# See https://w3c.github.io/webauthn/#sctn-verifying-assertion for
# the steps followed here. Memoized methods are called in their
# place in the step flow to make the process clearer.
def authenticate_security_key
return false if @params.blank?
# 3. Identify the user being authenticated and verify that this user is the
# owner of the public key credential source credentialSource identified by credential.id:
security_key = UserSecurityKey.find_by(credential_id: @params[:credentialId])
raise(NotFoundError, I18n.t('webauthn.validation.not_found_error')) if security_key.blank?
raise(OwnershipError, I18n.t('webauthn.validation.ownership_error')) if security_key.user != @current_user
# 4. Using credential.id (or credential.rawId, if base64url encoding is inappropriate for your use case),
# look up the corresponding credential public key and let credentialPublicKey be that credential public key.
public_key = security_key.public_key
# 5. Let cData, authData and sig denote the value of credentials response's clientDataJSON, authenticatorData, and signature respectively.
# 6. Let JSONtext be the result of running UTF-8 decode on the value of cData.
# 7. Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext.
client_data
# 8. Verify that the value of C.type is the string webauthn.get.
validate_webauthn_type(::Webauthn::ACCEPTABLE_AUTHENTICATION_TYPE)
# 9. Verify that the value of C.challenge equals the base64url encoding of options.challenge.
validate_challenge
# 10. Verify that the value of C.origin matches the Relying Party's origin.
validate_origin
# 11. Verify that the value of C.tokenBinding.status matches the state of Token Binding for the TLS connection
# over which the attestation was obtained. If Token Binding was used on that TLS connection, also verify
# that C.tokenBinding.id matches the base64url encoding of the Token Binding ID for the connection.
# Not using this right now.
# 12. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
validate_rp_id_hash
# 13. Verify that the User Present bit of the flags in authData is set.
# https://blog.bigbinary.com/2011/07/20/ruby-pack-unpack.html
#
# bit 0 is the least significant bit - LSB first
#
# 14. If user verification is required for this registration, verify that
# the User Verified bit of the flags in authData is set.
validate_user_verification
# 15. Verify that the values of the client extension outputs in clientExtensionResults and the authenticator
# extension outputs in the extensions in authData are as expected, considering the client extension input
# values that were given in options.extensions and any specific policy of the Relying Party regarding
# unsolicited extensions, i.e., those that were not specified as part of options.extensions. In the
# general case, the meaning of "are as expected" is specific to the Relying Party and which extensions are in use.
# Not using this right now.
# 16. Let hash be the result of computing a hash over response.clientDataJSON using SHA-256.
client_data_hash
# 17. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData and hash.
cose_key = COSE::Key.deserialize(Base64.decode64(security_key.public_key))
if !cose_key.to_pkey.verify(COSE::Algorithm.find(cose_key.alg).hash_function, signature, auth_data + client_data_hash)
raise(PublicKeyError, I18n.t('webauthn.validation.public_key_error'))
end
# Success! Update the last used at time for the key.
security_key.update(last_used: Time.zone.now)
rescue OpenSSL::PKey::PKeyError
raise(PublicKeyError, I18n.t('webauthn.validation.public_key_error'))
end
private
def auth_data
@auth_data ||= Base64.decode64(@params[:authenticatorData])
end
def signature
@signature ||= Base64.decode64(@params[:signature])
end
end
end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
module Webauthn
class SecurityKeyBaseValidationService
def initialize(current_user, params, challenge_params)
@current_user = current_user
@params = params
@challenge_params = challenge_params
end
def validate_webauthn_type(type_to_check)
return if client_data['type'] == type_to_check
raise(InvalidTypeError, I18n.t('webauthn.validation.invalid_type_error'))
end
def validate_challenge
return if challenge_match?
raise(ChallengeMismatchError, I18n.t('webauthn.validation.challenge_mismatch_error'))
end
def validate_origin
return if origin_match?
raise(InvalidOriginError, I18n.t('webauthn.validation.invalid_origin_error'))
end
def validate_rp_id_hash
return if rp_id_hash_match?
raise(InvalidRelyingPartyIdError, I18n.t('webauthn.validation.invalid_relying_party_id_error'))
end
def validate_user_verification
flags = auth_data[32].unpack("b*")[0].split('')
return if flags[0] == '1'
raise(UserVerificationError, I18n.t('webauthn.validation.user_verification_error'))
end
private
# https://w3c.github.io/webauthn/#sctn-registering-a-new-credential
# Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON.
def client_data_json
@client_data_json ||= Base64.decode64(@params[:clientData])
end
# Let C, the client data claimed as collected during the credential creation, be the result of running
# an implementation-specific JSON parser on JSONtext.
def client_data
@client_data ||= JSON.parse(client_data_json)
end
def challenge_match?
Base64.decode64(client_data['challenge']) == @challenge_params[:challenge]
end
def origin_match?
client_data['origin'] == @challenge_params[:origin]
end
def rp_id_hash_match?
auth_data[0..31] == OpenSSL::Digest::SHA256.digest(@challenge_params[:rp_id])
end
def client_data_hash
@client_data_hash ||= OpenSSL::Digest::SHA256.digest(client_data_json)
end
end
end

View File

@ -0,0 +1,150 @@
# frozen_string_literal: true
require 'cbor'
require 'cose'
module Webauthn
class SecurityKeyRegistrationService < SecurityKeyBaseValidationService
##
# See https://w3c.github.io/webauthn/#sctn-registering-a-new-credential for
# the registration steps followed here. Memoized methods are called in their
# place in the step flow to make the process clearer.
def register_second_factor_security_key
# 4. Verify that the value of C.type is webauthn.create.
validate_webauthn_type(::Webauthn::ACCEPTABLE_REGISTRATION_TYPE)
# 5. Verify that the value of C.challenge equals the base64url encoding of options.challenge.
validate_challenge
# 6. Verify that the value of C.origin matches the Relying Party's origin.
validate_origin
# 7. Verify that the value of C.tokenBinding.status matches the state of Token Binding for the TLS
# connection over which the assertion was obtained. If Token Binding was used on that TLS connection,
# also verify that C.tokenBinding.id matches the base64url encoding of the Token Binding ID for the connection.
# Not using this right now.
# 8. Let hash be the result of computing a hash over response.clientDataJSON using SHA-256.
client_data_hash
# 9. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
# structure to obtain the attestation statement format fmt, the authenticator data authData,
# and the attestation statement attStmt.
attestation
# 10. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
# check the SHA256 hash of the rpId is the same as the authData bytes 0..31
validate_rp_id_hash
# 11. Verify that the User Present bit of the flags in authData is set.
# https://blog.bigbinary.com/2011/07/20/ruby-pack-unpack.html
#
# bit 0 is the least significant bit - LSB first
#
# 12. If user verification is required for this registration, verify that
# the User Verified bit of the flags in authData is set.
validate_user_verification
# 13. Verify that the "alg" parameter in the credential public key in authData matches the alg
# attribute of one of the items in options.pubKeyCredParams.
# https://w3c.github.io/webauthn/#table-attestedCredentialData
# See https://www.iana.org/assignments/cose/cose.xhtml#algorithms for supported algorithm
# codes, -7 which Discourse uses is ECDSA w/ SHA-256
credential_public_key, credential_public_key_bytes, credential_id = extract_public_key_and_credential_from_attestation(auth_data)
raise(UnsupportedPublicKeyAlgorithmError, I18n.t('webauthn.validation.unsupported_public_key_algorithm_error')) if ::Webauthn::SUPPORTED_ALGORITHMS.exclude?(credential_public_key.alg)
# 14. Verify that the values of the client extension outputs in clientExtensionResults and the authenticator
# extension outputs in the extensions in authData are as expected, considering the client extension input
# values that were given in options.extensions. In particular, any extension identifier values in the
# clientExtensionResults and the extensions in authData MUST also be present as extension identifier values
# in options.extensions, i.e., no extensions are present that were not requested. In the general case, the
# meaning of "are as expected" is specific to the Relying Party and which extensions are in use.
# Not using this right now.
# 15. Determine the attestation statement format by performing a USASCII case-sensitive match on fmt against the
# set of supported WebAuthn Attestation Statement Format Identifier values. An up-to-date list of registered
# WebAuthn Attestation Statement Format Identifier values is maintained in the IANA registry of the same
# name [WebAuthn-Registries].
# 16. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature,
# by using the attestation statement format fmts verification procedure given attStmt, authData and hash.
if ::Webauthn::VALID_ATTESTATION_FORMATS.exclude?(attestation['fmt']) || attestation['fmt'] != 'none'
raise(UnsupportedAttestationFormatError, I18n.t('webauthn.validation.unsupported_attestation_format_error'))
end
#==================================================
# ONLY APPLIES IF fmt !== none, this is all to do with
# verifying attestation. May want to come back to this at
# some point for additional security.
#==================================================
#
# 17. If validation is successful, obtain a list of acceptable trust anchors (attestation root certificates or
# ECDAA-Issuer public keys) for that attestation type and attestation statement format fmt, from a trusted
# source or from policy. For example, the FIDO Metadata Service [FIDOMetadataService] provides one way
# to obtain such information, using the aaguid in the attestedCredentialData in authData.
#
# 18. Assess the attestation trustworthiness using the outputs of the verification procedure in step 16, as follows:
# If no attestation was provided, verify that None attestation is acceptable under Relying Party policy.
#==================================================
# 19. Check that the credentialId is not yet registered to any other user. If registration
# is requested for a credential that is already registered to a different user,
# the Relying Party SHOULD fail this registration ceremony, or it MAY decide to accept
# the registration, e.g. while deleting the older registration.
encoded_credential_id = Base64.strict_encode64(credential_id)
endcoded_public_key = Base64.strict_encode64(credential_public_key_bytes)
raise(CredentialIdInUseError, I18n.t('webauthn.validation.credential_id_in_use_error')) if UserSecurityKey.exists?(credential_id: encoded_credential_id)
# 20. If the attestation statement attStmt verified successfully and is found to be trustworthy,
# then register the new credential with the account that was denoted in options.user, by
# associating it with the credentialId and credentialPublicKey in the attestedCredentialData
# in authData, as appropriate for the Relying Party's system.
UserSecurityKey.create(
user: @current_user,
credential_id: encoded_credential_id,
public_key: endcoded_public_key,
name: @params[:name],
factor_type: UserSecurityKey.factor_types[:second_factor]
)
rescue CBOR::UnpackError, CBOR::TypeError, CBOR::MalformedFormatError, CBOR::StackError
raise MalformedAttestationError, I18n.t('webauthn.validation.malformed_attestation_error')
end
private
def attestation
@attestation ||= CBOR.decode(Base64.decode64(@params[:attestation]))
end
def auth_data
@auth_data ||= attestation['authData']
end
def extract_public_key_and_credential_from_attestation(auth_data)
# see https://w3c.github.io/webauthn/#authenticator-data for lengths
# of authdata for extraction
rp_id_length = 32
flags_length = 1
sign_count_length = 4
attested_credential_data_start_position = rp_id_length + flags_length + sign_count_length # 37
attested_credential_data_length = auth_data.size - attested_credential_data_start_position
attested_credential_data = auth_data[
attested_credential_data_start_position..(attested_credential_data_start_position + attested_credential_data_length - 1)
]
# see https://w3c.github.io/webauthn/#attested-credential-data for lengths
# of data for extraction
aa_guid = attested_credential_data[0..15]
credential_id_length = attested_credential_data[16..17].unpack("n*")[0]
credential_id = attested_credential_data[18..(18 + credential_id_length - 1)]
public_key_start_position = 18 + credential_id_length
public_key_bytes = attested_credential_data[
public_key_start_position..(public_key_start_position + attested_credential_data.size - 1)
]
public_key = COSE::Key.deserialize(public_key_bytes)
[public_key, public_key_bytes, credential_id]
end
end
end

26
lib/webauthn/webauthn.rb Normal file
View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'webauthn/security_key_base_validation_service'
require 'webauthn/security_key_registration_service'
require 'webauthn/security_key_authentication_service'
module Webauthn
ACCEPTABLE_REGISTRATION_TYPE = "webauthn.create".freeze
ACCEPTABLE_AUTHENTICATION_TYPE = "webauthn.get".freeze
SUPPORTED_ALGORITHMS = [-7].freeze
VALID_ATTESTATION_FORMATS = ['none', 'packed', 'fido-u2f'].freeze
class SecurityKeyError < StandardError; end
class InvalidOriginError < SecurityKeyError; end
class InvalidRelyingPartyIdError < SecurityKeyError; end
class UserVerificationError < SecurityKeyError; end
class ChallengeMismatchError < SecurityKeyError; end
class InvalidTypeError < SecurityKeyError; end
class UnsupportedPublicKeyAlgorithmError < SecurityKeyError; end
class UnsupportedAttestationFormatError < SecurityKeyError; end
class CredentialIdInUseError < SecurityKeyError; end
class MalformedAttestationError < SecurityKeyError; end
class NotFoundError < SecurityKeyError; end
class OwnershipError < SecurityKeyError; end
class PublicKeyError < SecurityKeyError; end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
Fabricator(:user_security_key) do
user
# Note: these values are valid and decode to a credential ID and COSE public key
# HOWEVER they are largely useless unless you have the device that created
# them. It is nice to have an approximation though.
credential_id { 'mJAJ4CznTO0SuLkJbYwpgK75ao4KMNIPlU5KWM92nq39kRbXzI9mSv6GxTcsMYoiPgaouNw7b7zBiS4vsQaO6A==' }
public_key { 'pQECAyYgASFYIMNgw4GCpwBUlR2SznJ1yY7B9yFvsuxhfo+C9kcA4IitIlggRdofrCezymy2B/YarX+gfB6gZKg648/cHIMjf6wWmmU=' }
enabled true
factor_type { UserSecurityKey.factor_types[:second_factor] }
name { sequence(:name) { |i| "Security Key #{i + 1}" } }
end
##
# Useful for specs that just need a user security key model but not
# any of the related usefulness as a webauthn credential, because the
# credential_id has a UNIQUE index
Fabricator(:user_security_key_with_random_credential, from: :user_security_key) do
credential_id { SecureRandom.base64(40) }
public_key { SecureRandom.base64(40) }
end

View File

@ -0,0 +1,134 @@
# frozen_string_literal: true
require 'rails_helper'
require 'webauthn/webauthn'
require 'webauthn/security_key_registration_service'
describe Webauthn::SecurityKeyAuthenticationService do
let(:security_key_user) { current_user }
let(:security_key) do
Fabricate(
:user_security_key,
credential_id: 'mJAJ4CznTO0SuLkJbYwpgK75ao4KMNIPlU5KWM92nq39kRbXzI9mSv6GxTcsMYoiPgaouNw7b7zBiS4vsQaO6A==',
public_key: 'pQECAyYgASFYIMNgw4GCpwBUlR2SznJ1yY7B9yFvsuxhfo+C9kcA4IitIlggRdofrCezymy2B/YarX+gfB6gZKg648/cHIMjf6wWmmU=',
user: security_key_user,
last_used: nil
)
end
let(:credential_id) { security_key.credential_id }
let(:challenge) { '81d4acfbd69eafa8f02bc2ecbec5267be8c9b28c1e0ba306d52b79f0f13d' }
let(:client_data_challenge) { Base64.strict_encode64(challenge) }
let(:client_data_webauthn_type) { 'webauthn.get' }
let(:client_data_origin) { 'http://localhost:3000' }
##
# IMPORTANT: For the SHA256 hash to match the same one as was used to generate
# the values for this spec, the three keys and values must be in the same order
# (challenge, origin, type)
let(:client_data_param) {
{
challenge: client_data_challenge,
origin: client_data_origin,
type: client_data_webauthn_type
}
}
##
# These are sourced from an actual login using the UserSecurityKey credential
# defined in this spec.
let(:signature) { "MEUCIBppPyK8blxBDoktU54mI1vWEY96r1V5H1rEBtPDxwcGAiEAoi7LCmMoEAuWYu0krZpflZlULsbURCGcqOwP06amXYE=" }
let(:authenticator_data) { "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MBAAAAVw==" }
let(:params) do
{
clientData: Base64.strict_encode64(client_data_param.to_json),
credentialId: credential_id,
authenticatorData: authenticator_data,
signature: signature
}
end
##
# The original key was generated in localhost
let(:rp_id) { 'localhost' }
let(:challenge_params) do
{
challenge: challenge,
rp_id: rp_id,
origin: 'http://localhost:3000'
}
end
let(:current_user) { Fabricate(:user) }
let(:subject) { described_class.new(current_user, params, challenge_params) }
it 'updates last_used when valid' do
subject.authenticate_security_key
expect(security_key.reload.last_used).not_to eq(nil)
end
context 'when the credential ID does not match any user security key in the database' do
let(:credential_id) { 'badid' }
it 'raises a NotFoundError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::NotFoundError, I18n.t('webauthn.validation.not_found_error')
)
end
end
context 'when the credential ID does exist but it is for a different user' do
let(:security_key_user) { Fabricate(:user) }
it 'raises an OwnershipError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::OwnershipError, I18n.t('webauthn.validation.ownership_error')
)
end
end
context 'when the client data webauthn type is not webauthn.get' do
let(:client_data_webauthn_type) { 'webauthn.explode' }
it 'raises an InvalidTypeError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::InvalidTypeError, I18n.t('webauthn.validation.invalid_type_error')
)
end
end
context 'when the decoded challenge does not match the original challenge provided by the server' do
let(:client_data_challenge) { Base64.strict_encode64('invalid challenge') }
it 'raises a ChallengeMismatchError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::ChallengeMismatchError, I18n.t('webauthn.validation.challenge_mismatch_error')
)
end
end
context 'when the origin of the client data does not match the server origin' do
let(:client_data_origin) { 'https://someothersite.com' }
it 'raises a InvalidOriginError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::InvalidOriginError, I18n.t('webauthn.validation.invalid_origin_error')
)
end
end
context 'when the sha256 hash of the relaying party ID does not match the one in attestation.authData' do
let(:rp_id) { 'bad_rp_id' }
it 'raises a InvalidRelyingPartyIdError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::InvalidRelyingPartyIdError, I18n.t('webauthn.validation.invalid_relying_party_id_error')
)
end
end
context 'when there is a problem verifying the public key (e.g. invalid signature)' do
let(:signature) { Base64.strict_encode64('badsig') }
it 'raises a PublicKeyError' do
expect { subject.authenticate_security_key }.to raise_error(
Webauthn::PublicKeyError, I18n.t('webauthn.validation.public_key_error')
)
end
end
end

View File

@ -0,0 +1,155 @@
# frozen_string_literal: true
require 'rails_helper'
require 'webauthn/webauthn'
require 'webauthn/security_key_registration_service'
describe Webauthn::SecurityKeyRegistrationService do
let(:client_data_challenge) { Base64.encode64(challenge) }
let(:client_data_webauthn_type) { 'webauthn.create' }
let(:client_data_origin) { 'http://localhost:3000' }
let(:client_data_param) {
{
challenge: client_data_challenge,
type: client_data_webauthn_type,
origin: client_data_origin
}
}
##
# This attestation object was sourced by manually registering
# a key with `navigator.credentials.create` and capturing the
# results in localhost.
let(:attestation) do
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQFmvayWc8OPJ4jj4sevfxBmvUglDMZrFalyokYrdnqOVvudC0lQialaGQv72eBzJM2Qn1GfJI7lpBgFJMprisLSlAQIDJiABIVgg+23/BZux7LK0/KQgCiQGtdr51ar+vfTtHWpRtN17gOwiWCBstV918mugVBexg/rdZjTs0wN/upHFoyBiAJCaGVD8OA=="
end
let(:params) do
{
clientData: Base64.encode64(client_data_param.to_json),
attestation: attestation,
name: 'My Yubikey'
}
end
##
# The above attestation was generated in localhost; Discourse.current_hostname
# returns test.localhost which we do not want
let(:rp_id) { 'localhost' }
let(:challenge_params) do
{
challenge: challenge,
rp_id: rp_id,
origin: 'http://localhost:3000'
}
end
let(:challenge) { 'f1e04530f34a1b6a08d032d8550e23eb8330be04e4166008f26c0e1b42ad' }
let(:current_user) { Fabricate(:user) }
let(:subject) { described_class.new(current_user, params, challenge_params) }
context 'when the client data webauthn type is not webauthn.create' do
let(:client_data_webauthn_type) { 'webauthn.explode' }
it 'raises an InvalidTypeError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::InvalidTypeError, I18n.t('webauthn.validation.invalid_type_error')
)
end
end
context 'when the decoded challenge does not match the original challenge provided by the server' do
let(:client_data_challenge) { Base64.encode64('invalid challenge') }
it 'raises a ChallengeMismatchError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::ChallengeMismatchError, I18n.t('webauthn.validation.challenge_mismatch_error')
)
end
end
context 'when the origin of the client data does not match the server origin' do
let(:client_data_origin) { 'https://someothersite.com' }
it 'raises a InvalidOriginError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::InvalidOriginError, I18n.t('webauthn.validation.invalid_origin_error')
)
end
end
context 'when the sha256 hash of the relaying party ID does not match the one in attestation.authData' do
let(:rp_id) { 'bad_rp_id' }
it 'raises a InvalidRelyingPartyIdError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::InvalidRelyingPartyIdError, I18n.t('webauthn.validation.invalid_relying_party_id_error')
)
end
end
context 'when the public key algorithm is not supported by the server' do
before do
@original_supported_alg_value = Webauthn::SUPPORTED_ALGORITHMS
silence_warnings do
Webauthn::SUPPORTED_ALGORITHMS = [-257]
end
end
it 'raises a UnsupportedPublicKeyAlgorithmError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::UnsupportedPublicKeyAlgorithmError, I18n.t('webauthn.validation.unsupported_public_key_algorithm_error')
)
end
after do
silence_warnings do
Webauthn::SUPPORTED_ALGORITHMS = @original_supported_alg_value
end
end
end
context 'when the attestation format is not supported' do
before do
@original_supported_alg_value = Webauthn::VALID_ATTESTATION_FORMATS
silence_warnings do
Webauthn::VALID_ATTESTATION_FORMATS = ['err']
end
end
it 'raises a UnsupportedAttestationFormatError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::UnsupportedAttestationFormatError, I18n.t('webauthn.validation.unsupported_attestation_format_error')
)
end
after do
silence_warnings do
Webauthn::VALID_ATTESTATION_FORMATS = @original_supported_alg_value
end
end
end
context 'when the credential id is already in use for any user' do
it 'raises a CredentialIdInUseError' do
# register the key to the current user
security_key = subject.register_second_factor_security_key
# update the key to be on a different user
other_user = Fabricate(:user)
security_key.update(user: other_user)
# error!
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::CredentialIdInUseError, I18n.t('webauthn.validation.credential_id_in_use_error')
)
end
end
context 'when the attestation data is malformed' do
let(:attestation) do
"blah/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQFmvayWc8OPJ4jj4sevfxBmvUglDMZrFalyokYrdnqOVvudC0lQialaGQv72eBzJM2Qn1GfJI7lpBgFJMprisLSlAQIDJiABIVgg+23/BZux7LK0/KQgCiQGtdr51ar+vfTtHWpRtN17gOwiWCBstV918mugVBexg/rdZjTs0wN/upHFoyBiAJCaGVD8OA=="
end
it 'raises a MalformedAttestationError' do
expect { subject.register_second_factor_security_key }.to raise_error(
Webauthn::MalformedAttestationError, I18n.t('webauthn.validation.malformed_attestation_error')
)
end
end
end

View File

@ -2170,4 +2170,47 @@ describe User do
end
end
end
describe "Second-factor authenticators" do
describe "#totps" do
it "only includes enabled totp 2FA" do
enabled_totp_2fa = Fabricate(:user_second_factor_totp, user: user, name: 'Enabled TOTP', enabled: true)
disabled_totp_2fa = Fabricate(:user_second_factor_totp, user: user, name: 'Disabled TOTP', enabled: false)
expect(user.totps.map(&:id)).to eq([enabled_totp_2fa.id])
end
end
describe "#security_keys" do
it "only includes enabled security_key 2FA" do
enabled_security_key_2fa = Fabricate(:user_security_key_with_random_credential, user: user, name: 'Enabled YubiKey', enabled: true)
disabled_security_key_2fa = Fabricate(:user_security_key_with_random_credential, user: user, name: 'Disabled YubiKey', enabled: false)
expect(user.security_keys.map(&:id)).to eq([enabled_security_key_2fa.id])
end
end
end
describe 'Secure identifier for a user which is a string other than the ID used to identify the user in some cases e.g. security keys' do
describe '#create_or_fetch_secure_identifier' do
context 'if the user already has a secure identifier' do
let(:sec_ident) { SecureRandom.hex(20) }
before do
user.update(secure_identifier: sec_ident)
end
it 'returns the identifier' do
expect(user.create_or_fetch_secure_identifier).to eq(sec_ident)
end
end
context 'if the user already does not have a secure identifier' do
it 'creates one' do
expect(user.secure_identifier).to eq(nil)
user.create_or_fetch_secure_identifier
expect(user.reload.secure_identifier).not_to eq(nil)
end
end
end
end
end

View File

@ -65,6 +65,23 @@ RSpec.describe SessionController do
expect(JSON.parse(response.body)["backup_codes_enabled"]).to eq(true)
end
end
context 'user has security key enabled' do
let!(:user_security_key) { Fabricate(:user_security_key, user: user) }
it "includes that information in the response" do
get "/session/email-login/#{email_token.token}.json"
expect(JSON.parse(response.body)["can_login"]).to eq(true)
expect(JSON.parse(response.body)["security_key_required"]).to eq(true)
expect(JSON.parse(response.body)["second_factor_required"]).to eq(nil)
expect(JSON.parse(response.body)["backup_codes_enabled"]).to eq(nil)
expect(JSON.parse(response.body)["allowed_credential_ids"]).to eq([user_security_key.credential_id])
secure_session = SecureSession.new(session["secure_session_id"])
expect(JSON.parse(response.body)["challenge"]).to eq(secure_session["staged-webauthn-challenge-#{user.id}"])
expect(secure_session["staged-webauthn-rp-id-#{user.id}"]).to eq(Discourse.current_hostname)
end
end
end
end

View File

@ -200,7 +200,7 @@ describe UsersController do
expect(response.status).to eq(200)
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"backup_enabled":false}')
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"security_key_required":false,"backup_enabled":false}')
end
expect(session["password-#{token}"]).to be_blank
@ -313,7 +313,7 @@ describe UsersController do
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"backup_enabled":false}')
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"security_key_required":false,"backup_enabled":false}')
end
put "/u/password-reset/#{token}", params: {
@ -346,6 +346,58 @@ describe UsersController do
expect(user.user_auth_tokens.count).to eq(1)
end
end
context 'security key authentication required' do
let!(:security_key) { Fabricate(:user_security_key, user: user, factor_type: UserSecurityKey.factor_types[:second_factor]) }
it 'preloads with a security key challenge and allowed credential ids' do
token = user.email_tokens.create!(email: user.email).token
get "/u/password-reset/#{token}"
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
password_reset = JSON.parse(json['password_reset'])
expect(password_reset['challenge']).not_to eq(nil)
expect(password_reset['allowed_credential_ids']).to eq([security_key.credential_id])
expect(password_reset['security_key_required']).to eq(true)
end
end
it 'stages a webauthn challenge and rp-id for the user' do
token = user.email_tokens.create!(email: user.email).token
get "/u/password-reset/#{token}"
secure_session = SecureSession.new(session["secure_session_id"])
expect(secure_session["staged-webauthn-challenge-#{user.id}"]).not_to eq(nil)
expect(secure_session["staged-webauthn-rp-id-#{user.id}"]).to eq(Discourse.current_hostname)
end
it 'changes password with valid security key challenge and authentication' do
token = user.email_tokens.create(email: user.email).token
get "/u/password-reset/#{token}"
::Webauthn::SecurityKeyAuthenticationService.any_instance.stubs(:authenticate_security_key).returns(true)
put "/u/password-reset/#{token}", params: {
password: 'hg9ow8yHG32O',
security_key_credential: {
signature: 'test',
clientData: 'test',
authenticatorData: 'test',
credentialId: 'test'
},
second_factor_method: UserSecondFactor.methods[:security_key]
}
user.reload
expect(response.status).to eq(200)
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true)
expect(user.user_auth_tokens.count).to eq(1)
end
end
end
context 'submit change' do
@ -500,6 +552,55 @@ describe UsersController do
expect(session[:current_user_id]).to eq(admin.id)
end
end
describe 'when security key authentication required' do
fab!(:security_key) { Fabricate(:user_security_key, user: admin) }
fab!(:email_token) { Fabricate(:email_token, user: admin) }
it 'does not log in when token required' do
security_key
get "/u/admin-login/#{email_token.token}"
expect(response).not_to redirect_to('/')
expect(session[:current_user_id]).not_to eq(admin.id)
expect(response.body).to include(I18n.t('login.security_key_authenticate'))
end
describe 'invalid security key' do
it 'should display the right error' do
::Webauthn::SecurityKeyAuthenticationService.any_instance.stubs(:authenticate_security_key).returns(false)
put "/u/admin-login/#{email_token.token}", params: {
security_key_credential: {
signature: 'test',
clientData: 'test',
authenticatorData: 'test',
credentialId: 'test'
}.to_json,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(response.body).to include(I18n.t('login.security_key_invalid'))
end
end
it 'logs in when a valid security key is given' do
::Webauthn::SecurityKeyAuthenticationService.any_instance.stubs(:authenticate_security_key).returns(true)
put "/u/admin-login/#{email_token.token}", params: {
security_key_credential: {
signature: 'test',
clientData: 'test',
authenticatorData: 'test',
credentialId: 'test'
}.to_json,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response).to redirect_to('/')
expect(session[:current_user_id]).to eq(admin.id)
end
end
end
end
@ -3540,4 +3641,88 @@ describe UsersController do
end
end
describe '#list_second_factors' do
before do
sign_in(user)
end
context 'when SSO is enabled' do
before do
SiteSetting.sso_url = 'https://discourse.test/sso'
SiteSetting.enable_sso = true
end
it 'does not allow access' do
post "/u/second_factors.json"
expect(response.status).to eq(404)
end
end
context 'when local logins are not enabled' do
before do
SiteSetting.enable_local_logins = false
end
it 'does not allow access' do
post "/u/second_factors.json"
expect(response.status).to eq(404)
end
end
context 'when the site settings allow second factors' do
before do
SiteSetting.enable_local_logins = true
SiteSetting.enable_sso = false
end
context 'when the password parameter is not provided' do
let(:password) { '' }
before do
post "/u/second_factors.json", params: { password: password }
end
it 'returns password required response' do
expect(response.status).to eq(200)
response_body = JSON.parse(response.body)
expect(response_body['password_required']).to eq(true)
end
end
context 'when the password is provided' do
let(:user) { Fabricate(:user, password: '8555039dd212cc66ec68') }
context 'when the password is correct' do
let(:password) { '8555039dd212cc66ec68' }
it 'returns a list of enabled totps and security_key second factors' do
totp_second_factor = Fabricate(:user_second_factor_totp, user: user)
security_key_second_factor = Fabricate(:user_security_key, user: user, factor_type: UserSecurityKey.factor_types[:second_factor])
post "/u/second_factors.json", params: { password: password }
expect(response.status).to eq(200)
response_body = JSON.parse(response.body)
expect(response_body['totps'].map { |second_factor| second_factor['id'] }).to include(totp_second_factor.id)
expect(response_body['security_keys'].map { |second_factor| second_factor['id'] }).to include(security_key_second_factor.id)
end
end
context 'when the password is not correct' do
let(:password) { 'wrongpassword' }
it 'returns the incorrect password response' do
post "/u/second_factors.json", params: { password: password }
response_body = JSON.parse(response.body)
expect(response_body['error']).to eq(
I18n.t("login.incorrect_password")
)
end
end
end
end
end
end

View File

@ -101,6 +101,32 @@ QUnit.test("second factor", async assert => {
);
});
QUnit.test("security key", async assert => {
await visit("/");
await click("header .login-button");
assert.ok(exists(".login-modal"), "it shows the login modal");
await fillIn("#login-account-name", "eviltrout");
await fillIn("#login-account-password", "need-security-key");
await click(".modal-footer .btn-primary");
assert.not(exists("#modal-alert:visible"), "it hides the login error");
assert.not(
exists("#credentials:visible"),
"it hides the username and password prompt"
);
assert.not(
exists("#login-second-factor:visible"),
"it does not display the second factor prompt"
);
assert.ok(
exists("#security-key:visible"),
"it shows the security key prompt"
);
assert.not(exists("#login-button:visible"), "hides the login button");
});
QUnit.test("create account", async assert => {
await visit("/");
await click("header .sign-up-button");

View File

@ -330,6 +330,20 @@ export default function() {
});
}
if (data.password === "need-security-key") {
if (data.securityKeyCredential) {
return response({ username: "eviltrout" });
}
return response({
error: "Invalid Security Key",
reason: "invalid_security_key",
backup_enabled: true,
sent_to_email: "eviltrout@example.com",
current_email: "current@example.com"
});
}
return response(400, { error: "invalid login" });
});