Skip to content

Commit

Permalink
feat: added passkey and webauthn support
Browse files Browse the repository at this point in the history
  • Loading branch information
titanism committed Dec 14, 2023
1 parent f4d3383 commit b67dae0
Show file tree
Hide file tree
Showing 45 changed files with 2,034 additions and 98 deletions.
1 change: 1 addition & 0 deletions .env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ AUTH_APPLE_ENABLED=false
AUTH_GOOGLE_ENABLED=false
AUTH_GITHUB_ENABLED=false
AUTH_OTP_ENABLED=false
AUTH_WEBAUTHN_ENABLED=true
# your sign-in with apple configuration
# https://github.com/nicokaiser/passport-apple#create-a-service
APPLE_CLIENT_ID=
Expand Down
1 change: 1 addition & 0 deletions .env.schema
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ AUTH_APPLE_ENABLED=
AUTH_GOOGLE_ENABLED=
AUTH_GITHUB_ENABLED=
AUTH_OTP_ENABLED=
AUTH_WEBAUTHN_ENABLED=
# your sign-in with apple configuration
# https://github.com/nicokaiser/passport-apple#create-a-service
APPLE_CLIENT_ID=
Expand Down
53 changes: 53 additions & 0 deletions app/models/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const options = { length: 10, type: 'numeric' };
const { fields } = config.passport;
const omitExtraFields = [
..._.without(mongooseOmitCommonFields.underscored.keys, 'email'),
'passkeys',
// TODO: change to allowlist
config.userFields.isRateLimitWhitelisted,
config.userFields.apiToken,
Expand Down Expand Up @@ -78,7 +79,35 @@ const omitExtraFields = [
config.userFields.smtpLimit
];

const Passkey = new mongoose.Schema({
nickname: {
type: String,
maxlength: 150,
trim: true
},
credentialId: {
type: String,
trim: true
},
publicKey: {
type: String,
trim: true
},
// sha256 is publicKey converted to base64 and then sha256 hashed
sha256: String
});

Passkey.plugin(mongooseCommonPlugin, {
object: 'passkey',
omitCommonFields: false,
omitExtraFields: ['_id', '__v'],
uniqueId: false,
locale: false
});

const Users = new mongoose.Schema({
// Passkeys
passkeys: [Passkey],
// Plan
plan: {
type: String,
Expand Down Expand Up @@ -527,6 +556,30 @@ Users.pre('save', function (next) {
next();
});

//
// allow user to have up to X passkeys at once
//
Users.pre('save', function (next) {
if (
Array.isArray(this.passkeys) &&
this.passkeys.length > config.passkeyLimit
) {
const error = Boom.badRequest(
i18n.api.t(
{
phrase: config.i18n.phrases.PASSKEYS_MAX_LIMIT_EXCEEDED,
locale: this[config.lastLocaleField]
},
config.passkeyLimit
)
);
error.no_translate = true;
return next(error);
}

next();
});

Users.plugin(captainHook);

Users.virtual(config.userFields.addressHTML).get(function () {
Expand Down
19 changes: 18 additions & 1 deletion app/views/_register-or-login.pug
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ mixin registerOrLogin(verb, isModal = false)
= t("Don't have an account?")
= " "
= t("Click here")
if verb === 'sign in' && passport && passport.webauthn
#webauthn-container.d-none
noscript
.alert.alert-danger.font-weight-bold.text-center.border-top-0.border-left-0.border-right-0.rounded-0.small!= t("Please enable JavaScript to sign in with Passkey.")
a.btn-webauthn-login.btn-auth.text-center.mb-3.text-decoration-none.rounded-lg(
href="#",
role="button"
)
span.btn-auth-icon.btn-auth-icon-fill.lazyload(
data-src=manifest("img/fa-key.svg")
)
noscript
span.btn-auth-icon.btn-auth-icon-fill(
style=`background-image:url("${manifest("img/fa-key.svg")}")`
)
span.btn-auth-text.font-weight-bold= t(`${humanize(verb)} with Passkey`)
//- <https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple>
if passport && passport.apple
a.btn-auth.text-center.mb-3.text-decoration-none.rounded-lg(
Expand Down Expand Up @@ -82,7 +98,8 @@ mixin registerOrLogin(verb, isModal = false)
style=`background-image:url("${manifest("img/github-logo.svg")}")`
)
span.btn-auth-text.font-weight-bold= t(`${humanize(verb)} with GitHub`)
if passport && (passport.apple || passport.google || passport.github)

if passport && (passport.apple || passport.google || passport.github || (verb === 'sign in' && passport.webauthn))
.hr-text.d-flex.text-secondary.align-items-center= t("or")
- const action = verb === "sign up" ? "/register" : config.loginRoute;
form.ajax-form(action=l(action), method="POST")
Expand Down
2 changes: 1 addition & 1 deletion app/views/about/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ In May 2023, we launched our **outbound SMTP** feature for [sending email with S

In November 2023, we launched our [**encrypted mailbox storage**](/blog/docs/best-quantum-safe-encrypted-email-service) feature for [IMAP suppport](/faq#do-you-support-receiving-email-with-imap).

In December 2023, [we added support](/faq#do-you-support-pop3) for [POP3](https://en.wikipedia.org/wiki/Post_Office_Protocol).
In December 2023, [we added support](/faq#do-you-support-pop3) for [POP3](https://en.wikipedia.org/wiki/Post_Office_Protocol). We also added [support for passkeys and WebAuthn](/faq#do-you-support-passkeys-and-webauthn), which allow you to securely log in without requiring a password and two-factor authentication.

[arc]: https://en.wikipedia.org/wiki/Authenticated_Received_Chain
18 changes: 18 additions & 0 deletions app/views/faq/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
* [Does it support the plus + symbol for Gmail aliases](#does-it-support-the-plus--symbol-for-gmail-aliases)
* [Does it support sub-domains](#does-it-support-sub-domains)
* [Does this forward my email's headers](#does-this-forward-my-emails-headers)
* [Do you support passkeys and WebAuthn](#do-you-support-passkeys-and-webauthn)
* [Is this well-tested](#is-this-well-tested)
* [Do you pass along SMTP response messages and codes](#do-you-pass-along-smtp-response-messages-and-codes)
* [How do you prevent spammers and ensure good email forwarding reputation](#how-do-you-prevent-spammers-and-ensure-good-email-forwarding-reputation)
Expand Down Expand Up @@ -2781,6 +2782,23 @@ If you want `foo.example.com` to forward emails, then enter `foo` as the name/ho
Yes, absolutely.


## Do you support passkeys and WebAuthn

Yes! As of December 13, 2023 we have added support for passkeys [due to high demand](https://github.com/orgs/forwardemail/discussions/182).

Passkeys allow you to securely log in without requiring a password and two-factor authentication.

You can validate your identity with touch, facial recognition, device-based password, or PIN.

We allow you to manage up to 30 passkeys at once, so that you can log in with all of your devices with ease.

Learn more about passkeys at the following links:

* [Sign-in to your applications and websites with passkeys](https://support.google.com/android/answer/14124480?hl=en) (Google)
* [Use passkeys to sign in to apps and websites on iPhone](https://support.apple.com/guide/iphone/use-passkeys-to-sign-in-to-apps-and-websites-iphf538ea8d0/ios) (Apple)
* [Wikipedia article on Passkeys](https://en.wikipedia.org/wiki/Passkey_\(credential\))


## Is this well-tested

Yes, it has tests written with [ava](https://github.com/avajs/ava) and also has code coverage.
Expand Down
149 changes: 149 additions & 0 deletions app/views/my-account/security.pug
Original file line number Diff line number Diff line change
@@ -1,6 +1,124 @@
extends ../layout

block body
if !isBot(ctx.get('User-Agent')) && isSANB(ctx.query.passkey)
script(async, defer).
document.addEventListener(
"DOMContentLoaded",
function load() {
if (!window.jQuery) return setTimeout(load, 50);
$(function () {
$(`#modal-nickname-#{ctx.query.passkey}`).modal("show");
});
},
false
);

each passkey in user.passkeys
.modal.fade(
id=`modal-nickname-${passkey.id}`,
tabindex="-1",
role="dialog",
aria-labelledby=`modal-nickname-${passkey.id}-title`,
aria-hidden="true"
)
.modal-dialog(role="document")
.modal-content
.modal-header.text-center.d-block
h4.d-inline-block.ml-4(id=`modal-nickname-${passkey.id}-title`)= t("Edit Nickname")
button.close(
type="button",
data-dismiss="modal",
aria-label="Close"
)
span(aria-hidden="true") &times;
.modal-body
.text-center
form.ajax-form.confirm-prompt(
action=l(`/my-account/passkeys/${passkey.id}`),
method="POST"
)
input(type="hidden", name="_method", value="PUT")
.form-group.floating-label
input.form-control(
id=`input-${passkey.id}-nickname`,
type="text",
name="nickname",
value=passkey.nickname,
placeholder=t("Passkey Nickname")
)
label(for=`input-${passkey.id}-nickname`)= t("Nickname")
p.form-text.small.text-black.text-themed-50= t("Nickname has a max of 150 characters.")
button.btn.btn-lg.btn-block.btn-success(type="submit")
= t("Update Nickname")

#modal-manage-passkeys.modal.fade(
tabindex="-1",
role="dialog",
aria-labelledby="modal-manage-passkeys-title",
aria-hidden="true"
)
.modal-dialog.modal-xl(role="document")
.modal-content
.modal-header.d-block.text-center
h4#modal-manage-passkeys-title.d-inline-block.ml-4= t("Manage Passkeys")
button.close(type="button", data-dismiss="modal", aria-label="Close")
span(aria-hidden="true") &times;
.modal-body.text-center
.d-block.d-lg-none.mb-3.text-muted.small
= "("
= t("Scroll to the right to see entire table")
= ")"
.table-responsive
table.table.table-hover.table-bordered.table-sm.mb-0
thead.thead-dark
tr
th(scope="col")= t("Nickname")
th(scope="col")= t("Public Key (SHA256)")
th(scope="col")= t("Created")
th(scope="col")= t("Actions")
tbody
if !Array.isArray(user.passkeys) || user.passkeys.length === 0
tr
td(colspan=4)
.alert.alert-info.text-center.mb-0= t("No passkeys exist yet.")
else
each passkey in user.passkeys
tr
td.align-middle.small.text-monospace= passkey.nickname
td.align-middle.text-center.small.text-monospace
code.small= passkey.sha256
td.align-middle.text-center.dayjs(
data-time=new Date(passkey.created_at).getTime()
)
small= dayjs(passkey.created_at).format("M/D/YY h:mm A z")
td.align-middle
ul.list-inline.mb-0
li.list-inline-item
button.btn.btn-primary.btn-sm.text-nowrap(
data-toggle="modal",
data-dismiss="modal",
data-target=`#modal-nickname-${passkey.id}`,
type="button"
)
i.fa.fa-pencil
= " "
= t("Edit Nickname")
li.list-inline-item
form.ajax-form.confirm-prompt.d-inline-block(
action=l(`/my-account/passkeys/${passkey.id}`),
method="POST"
)
input(
type="hidden",
name="_method",
value="DELETE"
)
button.btn.btn-danger.btn-sm(type="submit")
i.fa.fa-trash
= " "
= t("Remove")

#modal-disable-otp.modal.fade(
tabindex="-1",
role="dialog",
Expand Down Expand Up @@ -56,6 +174,37 @@ block body
)
input(type="hidden", name="_method", value="DELETE")
button.btn.btn-sm.btn-danger(type="submit")= t("Delete Account")

if passport && passport.webauthn
.card.border-themed.mb-3
h2.h6.card-header= t("Passkey Authentication")
.card-body
h5= t("Configure Passkeys")
p
= t("Passkeys allow you to securely log in without requiring a password and two-factor authentication.")
= " "
= t("You can validate your identity with touch, facial recognition, device-based password, or PIN.")
= " "
= t("We allow you to manage up to 30 passkeys at once, so that you can log in with all of your devices with ease.")
a.btn-webauthn-register.btn.btn-primary.btn-md(
href="#",
role="button"
)= t("Add New Passkey")
.card-footer.text-right
ul.list-inline.mb-0
li.list-inline-item
button.btn.btn-primary(
data-toggle="modal",
data-target="#modal-manage-passkeys",
type="button"
)= t("Manage Passkeys")
li.list-inline-item
a.btn.btn-dark(
href=l("/faq#do-you-support-passkeys-and-webauthn"),
rel="noopener noreferrer",
target="_blank"
)= t("Learn more")

if passport && passport.otp
.card.border-themed.mb-3
h2.h6.card-header= t("Two-Factor Authentication")
Expand Down
1 change: 1 addition & 0 deletions assets/img/fa-key.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 2 additions & 43 deletions assets/js/billing.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ const $ = require('jquery');
const Swal = require('sweetalert2');
const URLParse = require('url-parse');
const qs = require('qs');
const superagent = require('superagent');
const { spinner: Spinner } = require('@ladjs/assets');

const sendRequest = require('./send-request');

const $formBilling = $('#form-billing');
const $stripeButtonContainer = $('#stripe-button-container');
const $paypalButtonContainer = $('#paypal-button-container');
Expand Down Expand Up @@ -47,48 +48,6 @@ const PAYPAL_MAPPING = {
}
};

async function sendRequest(body) {
const response = await superagent
.post(window.location.pathname)
.set({
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
})
.ok(() => true) // override so we can parse it ourselves
.send(body);

if (!response.ok) {
response.err = new Error(
response.statusText || response.text || 'Unsuccessful HTTP response'
);
if (
typeof response.body === 'object' &&
response.body !== null &&
typeof response.body.message === 'string'
) {
response.err = new Error(response.body.message);
} else if (
!Array.isArray(response.body) &&
typeof response.body === 'object' &&
response.body !== null &&
// attempt to utilize Stripe-inspired error messages
typeof response.body.error === 'object'
) {
if (response.body.error.message)
response.err = new Error(response.body.error.message);
if (response.body.error.stack)
response.err.stack = response.body.error.stack;
if (response.body.error.code)
response.err.code = response.body.error.code;
if (response.body.error.param)
response.err.param = response.body.error.param;
}
}

return response;
}

function createPayPalSubscription(data, actions) {
const duration = $paymentDuration.find('option:checked').val();
if (typeof window.USER_PLAN !== 'string')
Expand Down
Loading

0 comments on commit b67dae0

Please sign in to comment.