From 47d3916c389cc8f6d8816f849b0526ae145f8042 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Wed, 29 Jan 2025 06:15:22 +0900 Subject: [PATCH] auth refactor --- services/api/.env | 3 + .../api/__mocks__/@simplewebauthn/server.js | 16 +- services/api/__mocks__/google-auth-library.js | 11 +- services/api/jest.config.js | 7 +- services/api/openapi.json | 506 ++++++++---------- services/api/package.json | 7 +- services/api/src/emails/otp-login-code.md | 9 + services/api/src/emails/otp-login-link.md | 9 + services/api/src/emails/otp-signup-code.md | 9 + services/api/src/emails/otp-signup-link.md | 11 + services/api/src/emails/otp.md | 5 - services/api/src/models/definitions/user.json | 34 +- services/api/src/routes/__tests__/signup.js | 266 +++++++++ .../api/src/routes/auth/__tests__/apple.js | 167 ++---- .../api/src/routes/auth/__tests__/google.js | 237 +++----- .../src/routes/auth/__tests__/integration.js | 24 +- services/api/src/routes/auth/__tests__/otp.js | 151 ++++-- .../api/src/routes/auth/__tests__/passkey.js | 290 ++++------ .../api/src/routes/auth/__tests__/password.js | 205 +++---- .../api/src/routes/auth/__tests__/totp.js | 36 +- services/api/src/routes/auth/apple.js | 113 ++++ services/api/src/routes/auth/apple/index.js | 123 ----- services/api/src/routes/auth/google.js | 80 +++ services/api/src/routes/auth/google/index.js | 125 ----- services/api/src/routes/auth/otp.js | 94 ++++ services/api/src/routes/auth/otp/index.js | 127 ----- services/api/src/routes/auth/passkey.js | 108 ++++ services/api/src/routes/auth/passkey/index.js | 170 ------ services/api/src/routes/auth/passkey/utils.js | 128 ----- .../auth/{password/index.js => password.js} | 77 +-- .../routes/auth/{totp/index.js => totp.js} | 29 +- services/api/src/routes/auth/utils.js | 26 + services/api/src/routes/index.js | 2 + services/api/src/routes/invites.js | 18 +- services/api/src/routes/signup.js | 94 ++++ services/api/src/sms/otp-login-code.txt | 1 + services/api/src/sms/otp-login-link.txt | 1 + services/api/src/sms/otp-signup-code.txt | 1 + services/api/src/sms/otp-signup-link.txt | 1 + services/api/src/sms/otp.txt | 1 - .../apple/utils.js => utils/auth/apple.js} | 15 +- services/api/src/utils/auth/authenticators.js | 43 +- .../google/utils.js => utils/auth/google.js} | 26 +- services/api/src/utils/auth/index.js | 3 +- services/api/src/utils/auth/login.js | 7 +- services/api/src/utils/auth/otp.js | 51 +- services/api/src/utils/auth/passkey.js | 196 +++++++ services/api/src/utils/auth/password.js | 14 +- services/api/src/utils/auth/register.js | 24 - services/api/src/utils/auth/tokens.js | 65 ++- .../auth/totp/utils.js => utils/auth/totp.js} | 13 +- services/api/src/utils/auth/validation.js | 34 -- services/api/src/utils/messaging/index.js | 17 +- .../middleware/__tests__/authenticate.js | 4 +- .../api/src/utils/middleware/authenticate.js | 2 +- services/api/src/utils/middleware/tokens.js | 4 +- services/api/src/utils/testing/request.js | 4 +- .../api/src/utils/testing/setup/matchers.js | 26 + services/api/yarn.lock | 27 +- services/web/.env | 13 +- services/web/package.json | 6 +- services/web/src/{auth/App.js => AuthApp.js} | 9 +- services/web/src/OnboardApp.js | 20 + services/web/src/assets/apple-logo-white.svg | 7 + services/web/src/assets/google-logo.svg | 12 + .../components/Auth/Apple/DisableButton.js | 2 +- .../src/components/Auth/Apple/SignInButton.js | 149 ++---- .../web/src/components/Auth/Apple/apple.less | 34 ++ services/web/src/components/Auth/Federated.js | 49 +- .../components/Auth/Google/DisableButton.js | 2 +- .../components/Auth/Google/SignInButton.js | 138 ++--- .../src/components/Auth/Google/google.less | 28 + .../src/components/Auth/OptionalPassword.js | 10 + .../web/src/components/Auth/PasskeyButton.js | 60 +++ .../src/components/form-fields/Code/index.js | 28 +- .../web/src/components/form-fields/Email.js | 5 +- .../web/src/components/form-fields/Phone.js | 7 +- .../web/src/components/form-fields/Terms.js | 30 ++ services/web/src/docs/App.js | 2 +- .../web/src/docs/components/properties.less | 2 +- .../src/docs/pages/AppleAuthentication.mdx | 33 -- .../web/src/docs/pages/Authentication.mdx | 24 +- .../docs/pages/FederatedAuthentication.mdx | 58 ++ .../web/src/docs/pages/GettingStarted.mdx | 12 +- .../src/docs/pages/GoogleAuthentication.mdx | 33 -- .../web/src/docs/pages/OtpAuthentication.mdx | 31 +- .../src/docs/pages/PasskeyAuthentication.mdx | 55 +- .../src/docs/pages/PasswordAuthentication.mdx | 23 +- services/web/src/docs/pages/Signup.mdx | 19 + .../web/src/docs/pages/TotpAuthentication.mdx | 5 +- services/web/src/docs/pages/index.js | 26 +- .../src/docs/screens/ApiDocs/api-docs.less | 22 + .../web/src/docs/screens/ApiDocs/index.js | 54 +- services/web/src/docs/screens/Components.js | 2 - services/web/src/docs/screens/IconSheet.js | 3 +- services/web/src/index.js | 4 +- services/web/src/layouts/Sidebar/sidebar.less | 14 +- .../web/src/screens/Auth/AcceptInvite/Form.js | 25 +- .../src/screens/Auth/AcceptInvite/index.js | 2 - services/web/src/screens/Auth/ConfirmCode.js | 167 ++++++ .../web/src/screens/Auth/ForgotPassword.js | 3 - services/web/src/screens/Auth/Login.js | 156 ++++++ services/web/src/screens/Auth/Login/Code.js | 121 ----- .../web/src/screens/Auth/Login/EmailOtp.js | 112 ---- .../web/src/screens/Auth/Login/Passkey.js | 152 ------ .../web/src/screens/Auth/Login/Password.js | 155 ------ .../web/src/screens/Auth/Login/PhoneOtp.js | 113 ---- .../web/src/screens/Auth/ResetPassword.js | 2 - services/web/src/screens/Auth/Signup.js | 265 +++------ services/web/src/screens/Error/index.js | 2 - services/web/src/screens/Loading/index.js | 2 - services/web/src/screens/Onboard/index.js | 126 +++++ .../web/src/screens/Settings/Authenticator.js | 6 +- services/web/src/screens/Settings/Menu.js | 6 +- .../Settings/{Account.js => Profile.js} | 28 +- .../Settings/{Login.js => Security.js} | 191 +++---- services/web/src/screens/Settings/Sessions.js | 4 +- services/web/src/screens/Settings/index.js | 10 +- .../web/src/semantic/globals/site.variables | 2 +- .../web/src/stores/__tests__/session.test.js | 2 - services/web/src/stores/session.js | 5 +- services/web/src/styles/github-markdown.less | 12 +- .../Apple/utils.js => utils/auth/apple.js} | 65 ++- .../Google/utils.js => utils/auth/google.js} | 59 +- services/web/src/utils/auth/passkey.js | 92 ++++ services/web/src/utils/env.js | 3 + services/web/src/utils/object.js | 9 +- services/web/src/utils/passkey.js | 91 ---- services/web/webpack.config.js | 8 + services/web/yarn.lock | 85 ++- 130 files changed, 3490 insertions(+), 3519 deletions(-) create mode 100644 services/api/src/emails/otp-login-code.md create mode 100644 services/api/src/emails/otp-login-link.md create mode 100644 services/api/src/emails/otp-signup-code.md create mode 100644 services/api/src/emails/otp-signup-link.md delete mode 100644 services/api/src/emails/otp.md create mode 100644 services/api/src/routes/__tests__/signup.js create mode 100644 services/api/src/routes/auth/apple.js delete mode 100644 services/api/src/routes/auth/apple/index.js create mode 100644 services/api/src/routes/auth/google.js delete mode 100644 services/api/src/routes/auth/google/index.js create mode 100644 services/api/src/routes/auth/otp.js delete mode 100644 services/api/src/routes/auth/otp/index.js create mode 100644 services/api/src/routes/auth/passkey.js delete mode 100644 services/api/src/routes/auth/passkey/index.js delete mode 100644 services/api/src/routes/auth/passkey/utils.js rename services/api/src/routes/auth/{password/index.js => password.js} (59%) rename services/api/src/routes/auth/{totp/index.js => totp.js} (73%) create mode 100644 services/api/src/routes/auth/utils.js create mode 100644 services/api/src/routes/signup.js create mode 100644 services/api/src/sms/otp-login-code.txt create mode 100644 services/api/src/sms/otp-login-link.txt create mode 100644 services/api/src/sms/otp-signup-code.txt create mode 100644 services/api/src/sms/otp-signup-link.txt delete mode 100644 services/api/src/sms/otp.txt rename services/api/src/{routes/auth/apple/utils.js => utils/auth/apple.js} (68%) rename services/api/src/{routes/auth/google/utils.js => utils/auth/google.js} (54%) create mode 100644 services/api/src/utils/auth/passkey.js delete mode 100644 services/api/src/utils/auth/register.js rename services/api/src/{routes/auth/totp/utils.js => utils/auth/totp.js} (76%) delete mode 100644 services/api/src/utils/auth/validation.js create mode 100644 services/api/src/utils/testing/setup/matchers.js rename services/web/src/{auth/App.js => AuthApp.js} (85%) create mode 100644 services/web/src/OnboardApp.js create mode 100644 services/web/src/assets/apple-logo-white.svg create mode 100644 services/web/src/assets/google-logo.svg create mode 100644 services/web/src/components/Auth/Apple/apple.less create mode 100644 services/web/src/components/Auth/Google/google.less create mode 100644 services/web/src/components/Auth/OptionalPassword.js create mode 100644 services/web/src/components/Auth/PasskeyButton.js create mode 100644 services/web/src/components/form-fields/Terms.js delete mode 100644 services/web/src/docs/pages/AppleAuthentication.mdx create mode 100644 services/web/src/docs/pages/FederatedAuthentication.mdx delete mode 100644 services/web/src/docs/pages/GoogleAuthentication.mdx create mode 100644 services/web/src/docs/pages/Signup.mdx create mode 100644 services/web/src/screens/Auth/ConfirmCode.js create mode 100644 services/web/src/screens/Auth/Login.js delete mode 100644 services/web/src/screens/Auth/Login/Code.js delete mode 100644 services/web/src/screens/Auth/Login/EmailOtp.js delete mode 100644 services/web/src/screens/Auth/Login/Passkey.js delete mode 100644 services/web/src/screens/Auth/Login/Password.js delete mode 100644 services/web/src/screens/Auth/Login/PhoneOtp.js create mode 100644 services/web/src/screens/Onboard/index.js rename services/web/src/screens/Settings/{Account.js => Profile.js} (78%) rename services/web/src/screens/Settings/{Login.js => Security.js} (67%) rename services/web/src/{components/Auth/Apple/utils.js => utils/auth/apple.js} (53%) rename services/web/src/{components/Auth/Google/utils.js => utils/auth/google.js} (53%) create mode 100644 services/web/src/utils/auth/passkey.js delete mode 100644 services/web/src/utils/passkey.js diff --git a/services/api/.env b/services/api/.env index 4bd39930b..996d624d9 100644 --- a/services/api/.env +++ b/services/api/.env @@ -63,9 +63,12 @@ TWILIO_WEBHOOK_URL= TWILIO_AUTH_TOKEN=AC21717619d8cf45502cc2f7cd7aee139d # Sign in with Google +# https://developers.google.com/identity/sign-in/web/sign-in GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= # Sign in with Apple +# https://developer.apple.com/help/account/configure-app-capabilities/about-sign-in-with-apple/ APPLE_SERVICE_ID= # OpenAI diff --git a/services/api/__mocks__/@simplewebauthn/server.js b/services/api/__mocks__/@simplewebauthn/server.js index 0d3519421..8a9da3411 100644 --- a/services/api/__mocks__/@simplewebauthn/server.js +++ b/services/api/__mocks__/@simplewebauthn/server.js @@ -1,15 +1,9 @@ -function generateAuthenticationOptions() { +function generateAuthenticationOptions(options) { return { - rpId: 'rpID', - challenge: 'challenge', - allowCredentials: [ - { - id: 'id', - type: 'public-key', - transports: ['hybrid', 'internal'], - }, - ], + ...options, + rpID: 'rpID', timeout: 60000, + challenge: 'challenge', userVerification: 'preferred', }; } @@ -85,7 +79,7 @@ function verifyRegistrationResponse(options) { }, }; } else { - throw new Error('Bad register response.'); + throw new Error('Bad registration response.'); } } diff --git a/services/api/__mocks__/google-auth-library.js b/services/api/__mocks__/google-auth-library.js index bbe851e3f..4733e939d 100644 --- a/services/api/__mocks__/google-auth-library.js +++ b/services/api/__mocks__/google-auth-library.js @@ -1,4 +1,11 @@ class MockClient { + getToken(code) { + return { + tokens: { + id_token: code, + }, + }; + } verifyIdToken(options) { const { idToken } = options; @@ -16,7 +23,7 @@ class MockClient { } } -function createToken(payload) { +function createCode(payload) { payload.givenName ||= 'First Name'; payload.familyName ||= 'Last Name'; payload.email_verified ??= true; @@ -25,5 +32,5 @@ function createToken(payload) { module.exports = { OAuth2Client: MockClient, - createToken, + createCode, }; diff --git a/services/api/jest.config.js b/services/api/jest.config.js index f259cfb73..1f79e92e1 100644 --- a/services/api/jest.config.js +++ b/services/api/jest.config.js @@ -2,9 +2,14 @@ process.env.ENV_NAME = 'test'; process.env.LOG_LEVEL ||= 'warn'; module.exports = { preset: '@shelf/jest-mongodb', - setupFilesAfterEnv: ['/src/utils/testing/setup/autoclean', '/src/utils/testing/setup/database'], + setupFilesAfterEnv: [ + '/src/utils/testing/setup/autoclean', + '/src/utils/testing/setup/database', + '/src/utils/testing/setup/matchers', + ], // Only run on changed files without extra arguments. watchPlugins: ['./src/utils/testing/ChangedFilesPlugin'], // https://github.com/shelfio/jest-mongodb#6-jest-watch-mode-gotcha watchPathIgnorePatterns: ['globalConfig'], + maxWorkers: '50%', }; diff --git a/services/api/openapi.json b/services/api/openapi.json index 9a488283d..d8add889b 100644 --- a/services/api/openapi.json +++ b/services/api/openapi.json @@ -44,7 +44,7 @@ "/1/docs/generate": { "post": {} }, - "/1/auth/otp/send-code": { + "/1/auth/otp/send": { "post": { "requestBody": { "content": { @@ -52,6 +52,22 @@ "schema": { "type": "object", "properties": { + "type": { + "default": "link", + "type": "string", + "enum": [ + "link", + "code" + ] + }, + "transport": { + "default": "email", + "type": "string", + "enum": [ + "email", + "sms" + ] + }, "email": { "format": "email", "type": "string" @@ -78,6 +94,7 @@ "type": "object", "properties": { "code": { + "required": true, "type": "string" }, "email": { @@ -97,7 +114,7 @@ } } }, - "/1/auth/otp/register": { + "/1/auth/totp/login": { "post": { "requestBody": { "content": { @@ -105,42 +122,13 @@ "schema": { "type": "object", "properties": { - "email": { - "required": true, - "format": "email", - "type": "string" - }, "phone": { "format": "phone", "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", "type": "string", "x-generated": true }, - "firstName": { - "required": true, - "type": "string" - }, - "lastName": { - "required": true, - "type": "string" - } - } - } - } - } - } - } - }, - "/1/auth/totp/login": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "email": { - "required": true, "format": "email", "type": "string" }, @@ -201,7 +189,7 @@ ] } }, - "/1/auth/apple/login": { + "/1/auth/apple": { "post": { "requestBody": { "content": { @@ -212,38 +200,11 @@ "token": { "required": true, "type": "string" - } - } - } - } - } - } - } - }, - "/1/auth/apple/register": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "phone": { - "format": "phone", - "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", - "type": "string", - "x-generated": true }, "firstName": { - "required": true, "type": "string" }, "lastName": { - "required": true, - "type": "string" - }, - "token": { - "required": true, "type": "string" } } @@ -286,26 +247,7 @@ ] } }, - "/1/auth/google/login": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "required": true, - "type": "string" - } - } - } - } - } - } - } - }, - "/1/auth/google/register": { + "/1/auth/google": { "post": { "requestBody": { "content": { @@ -313,21 +255,7 @@ "schema": { "type": "object", "properties": { - "phone": { - "format": "phone", - "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", - "type": "string", - "x-generated": true - }, - "firstName": { - "required": true, - "type": "string" - }, - "lastName": { - "required": true, - "type": "string" - }, - "token": { + "code": { "required": true, "type": "string" } @@ -338,30 +266,6 @@ } } }, - "/1/auth/google/enable": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "required": true, - "type": "string" - } - } - } - } - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "/1/auth/google/disable": { "post": { "security": [ @@ -371,51 +275,10 @@ ] } }, - "/1/auth/passkey/login-generate": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "required": true, - "format": "email", - "type": "string" - } - } - } - } - } - } - } - }, - "/1/auth/passkey/login-verify": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "required": true, - "format": "email", - "type": "string" - }, - "response": { - "required": true, - "type": "object" - } - } - } - } - } - } - } + "/1/auth/passkey/generate-login": { + "post": {} }, - "/1/auth/passkey/register-generate": { + "/1/auth/passkey/verify-login": { "post": { "requestBody": { "content": { @@ -423,48 +286,15 @@ "schema": { "type": "object", "properties": { - "email": { + "token": { "required": true, - "format": "email", - "type": "string" - }, - "phone": { - "format": "phone", - "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", "type": "string", - "x-generated": true - }, - "firstName": { - "required": true, - "type": "string" - }, - "lastName": { - "required": true, - "type": "string" - } - } - } - } - } - } - } - }, - "/1/auth/passkey/register-verify": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "required": true, - "format": "email", - "type": "string" + "description": "The token from `generate-login`." }, "response": { "required": true, - "type": "object" + "type": "object", + "description": "Client options generated by the browser. See [SimpleWebAuthN](https://simplewebauthn.dev/docs/packages/browser) for more." } } } @@ -473,7 +303,7 @@ } } }, - "/1/auth/passkey/enable-generate": { + "/1/auth/passkey/generate-new": { "post": { "security": [ { @@ -482,7 +312,7 @@ ] } }, - "/1/auth/passkey/enable-verify": { + "/1/auth/passkey/verify-new": { "post": { "requestBody": { "content": { @@ -490,9 +320,15 @@ "schema": { "type": "object", "properties": { + "token": { + "required": true, + "type": "string", + "description": "The token from `generate-new`." + }, "response": { "required": true, - "type": "object" + "type": "object", + "description": "Client options generated by the browser. See [SimpleWebAuthN](https://simplewebauthn.dev/docs/packages/browser) for more." } } } @@ -506,8 +342,15 @@ ] } }, - "/1/auth/passkey/disable": { - "post": { + "/1/auth/passkey/:id": { + "delete": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true + } + ], "security": [ { "bearerAuth": [] @@ -515,46 +358,6 @@ ] } }, - "/1/auth/password/register": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "required": true, - "format": "email", - "type": "string" - }, - "phone": { - "format": "phone", - "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", - "type": "string", - "x-generated": true - }, - "firstName": { - "required": true, - "type": "string" - }, - "lastName": { - "required": true, - "type": "string" - }, - "password": { - "required": true, - "description": "A password of at least 12 characters.", - "type": "string", - "x-generated": true - } - } - } - } - } - } - } - }, "/1/auth/password/login": { "post": { "requestBody": { @@ -813,17 +616,22 @@ "oneOf": [ { "format": "email", + "nullable": true, "type": "string" }, { "type": "array", "items": { "format": "email", + "nullable": true, "type": "string" } } ] }, + "emailVerified": { + "type": "boolean" + }, "phone": { "oneOf": [ { @@ -845,9 +653,6 @@ } ] }, - "emailVerified": { - "type": "boolean" - }, "phoneVerified": { "type": "boolean" }, @@ -1102,22 +907,6 @@ "lastName": { "type": "string" }, - "email": { - "format": "email", - "type": "string" - }, - "phone": { - "format": "phone", - "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", - "nullable": true, - "type": "string" - }, - "emailVerified": { - "type": "boolean" - }, - "phoneVerified": { - "type": "boolean" - }, "roles": { "type": "array", "items": { @@ -1215,23 +1004,6 @@ "required": true, "type": "string" }, - "email": { - "required": true, - "format": "email", - "type": "string" - }, - "phone": { - "format": "phone", - "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", - "type": "string", - "x-generated": true - }, - "emailVerified": { - "type": "boolean" - }, - "phoneVerified": { - "type": "boolean" - }, "roles": { "type": "array", "items": { @@ -2792,6 +2564,171 @@ "/1/status/mongodb": { "get": {} }, + "/1/signup": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "default": "link", + "type": "string", + "enum": [ + "link", + "code", + "password" + ] + }, + "transport": { + "default": "email", + "type": "string", + "enum": [ + "email", + "sms" + ] + }, + "firstName": { + "required": true, + "type": "string" + }, + "lastName": { + "required": true, + "type": "string" + }, + "password": { + "description": "A password of at least 12 characters. Required when `type` is `password`.", + "type": "string", + "x-generated": true + }, + "email": { + "format": "email", + "type": "string", + "description": "Required when `transport` is `email`." + }, + "phone": { + "format": "phone", + "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format. Required when `transport` is `sms`.", + "type": "string", + "x-generated": true + } + } + }, + "examples": { + "7e8e3a23d61ed35c3a67f35f60f9a65d": { + "value": { + "type": "code", + "transport": "email", + "firstName": "Bedrock", + "lastName": "User", + "email": "user@bedrock.foundation" + } + }, + "c77a182214923d06a78096d515931c03": { + "value": { + "type": "password", + "firstName": "Bedrock", + "lastName": "User" + } + }, + "7f224651f863d77eefff3c13e5b3be6a": { + "value": { + "type": "password", + "firstName": "Bedrock", + "lastName": "User", + "password": "this is my password", + "email": "user@bedrock.foundation" + } + } + } + } + } + }, + "responses": { + "200": { + "headers": { + "vary": "Origin", + "access-control-allow-origin": "", + "access-control-expose-headers": "content-length,content-disposition", + "request-id": "", + "content-type": "application/json; charset=utf-8" + }, + "content": { + "application/json": { + "examples": { + "7f224651f863d77eefff3c13e5b3be6a": { + "value": { + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJraWQiOiJ1c2VyIiwic3ViIjoiNjc5OTNlOGIwMDE3OTY2ZTJjYzI5NjExIiwianRpIjoidl9TNTBlMUdnM18xeW9mIiwiaWF0IjoxNzM4MDk2MjY3LCJleHAiOjE3NDA2ODgyNjd9.xKYE1a1AQol-Nc9JbYPDcdCvxNVixAjAxYmnPz1Cj3g" + } + }, + "x-path": "/1/signup" + }, + "7e8e3a23d61ed35c3a67f35f60f9a65d": { + "value": { + "data": { + "challenge": { + "type": "code", + "transport": "email", + "email": "user@bedrock.foundation" + } + } + }, + "x-path": "/1/signup" + } + } + } + } + }, + "400": { + "headers": { + "vary": "Origin", + "access-control-allow-origin": "", + "access-control-expose-headers": "content-length,content-disposition", + "request-id": "", + "content-type": "application/json; charset=utf-8" + }, + "content": { + "application/json": { + "examples": { + "c77a182214923d06a78096d515931c03": { + "value": { + "error": { + "type": "validation", + "message": "Password is required.", + "status": 400, + "details": [ + { + "type": "field", + "details": [ + { + "message": "Password is required." + } + ], + "field": "password" + } + ] + } + }, + "x-path": "/1/signup" + } + } + } + } + }, + "500": { + "headers": { + "vary": "Origin", + "access-control-allow-origin": "", + "access-control-expose-headers": "content-length,content-disposition", + "request-id": "", + "content-type": "application/json; charset=utf-8" + } + } + } + } + }, "/1/categories/search": { "post": { "summary": "Search categories", @@ -4216,19 +4153,18 @@ "type": "string" }, "email": { - "required": true, "format": "email", "type": "string" }, + "emailVerified": { + "type": "boolean" + }, "phone": { "format": "phone", "description": "A phone number in [E.164](https://en.wikipedia.org/wiki/E.164) format.", "type": "string", "x-generated": true }, - "emailVerified": { - "type": "boolean" - }, "phoneVerified": { "type": "boolean" }, @@ -4697,4 +4633,4 @@ } } } -} \ No newline at end of file +} diff --git a/services/api/package.json b/services/api/package.json index d2317eb9c..c648649ad 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -22,10 +22,10 @@ }, "dependencies": { "@bedrockio/config": "^2.2.3", - "@bedrockio/fixtures": "^1.2.5", + "@bedrockio/fixtures": "^1.3.2", "@bedrockio/logger": "^1.0.8", - "@bedrockio/model": "^0.8.2", - "@bedrockio/yada": "^1.2.3", + "@bedrockio/model": "^0.8.4", + "@bedrockio/yada": "^1.2.8", "@google-cloud/storage": "^7.11.1", "@koa/cors": "^4.0.0", "@koa/router": "^12.0.0", @@ -46,6 +46,7 @@ "lodash": "^4.17.21", "marked": "^10.0.0", "mongoose": "^8.6.3", + "ms": "^2.1.3", "mustache": "^4.2.0", "nanoid": "3.3.5", "postmark": "^3.11.0", diff --git a/services/api/src/emails/otp-login-code.md b/services/api/src/emails/otp-login-code.md new file mode 100644 index 000000000..92158f5a6 --- /dev/null +++ b/services/api/src/emails/otp-login-code.md @@ -0,0 +1,9 @@ +--- +subject: Your Verification Code +--- + +Hi {{user.name}}, + +Use the code below to securely sign in to your account: + +## {{code}} diff --git a/services/api/src/emails/otp-login-link.md b/services/api/src/emails/otp-login-link.md new file mode 100644 index 000000000..295d0384f --- /dev/null +++ b/services/api/src/emails/otp-login-link.md @@ -0,0 +1,9 @@ +--- +subject: 'Login to {{APP_NAME}}' +--- + +Hi {{user.name}}, + +Click the button below to securely sign in to your account: + +[Login]({{APP_URL}}/confirm-code?code={{{code}}}) diff --git a/services/api/src/emails/otp-signup-code.md b/services/api/src/emails/otp-signup-code.md new file mode 100644 index 000000000..af86ee553 --- /dev/null +++ b/services/api/src/emails/otp-signup-code.md @@ -0,0 +1,9 @@ +--- +subject: Your Verification Code +--- + +Hi {{user.name}}, + +To activate your account, please use the code below: + +## {{code}} diff --git a/services/api/src/emails/otp-signup-link.md b/services/api/src/emails/otp-signup-link.md new file mode 100644 index 000000000..209f4cf44 --- /dev/null +++ b/services/api/src/emails/otp-signup-link.md @@ -0,0 +1,11 @@ +--- +subject: Your Verification Code +--- + +Hi {{user.name}}, + +To activate your account, please confirm your email address. + +Click the button below to verify your email: + +[Confirm Email]({{APP_URL}}/confirm-code?email={{user.email}}&code={{code}}) diff --git a/services/api/src/emails/otp.md b/services/api/src/emails/otp.md deleted file mode 100644 index 5100407a0..000000000 --- a/services/api/src/emails/otp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -subject: Your Login Code ---- - -Your login code is: {{code}} diff --git a/services/api/src/models/definitions/user.json b/services/api/src/models/definitions/user.json index b30c4cebc..c99b0bae1 100644 --- a/services/api/src/models/definitions/user.json +++ b/services/api/src/models/definitions/user.json @@ -11,22 +11,31 @@ "email": { "type": "String", "validate": "email", + "unique": true, "lowercase": true, - "required": true, - "unique": true + "writeAccess": [ + "admin", + "superAdmin" + ] }, "emailVerified": { "type": "Boolean", - "default": false + "default": false, + "writeAccess": "none" }, "phone": { "type": "String", "validate": "phone", - "unique": true + "unique": true, + "writeAccess": [ + "admin", + "superAdmin" + ] }, "phoneVerified": { "type": "Boolean", - "default": false + "default": false, + "writeAccess": "none" }, "roles": [ { @@ -71,9 +80,14 @@ "password" ] }, - "id": { - "type": "String", - "readAccess": "none" + "name": { + "type": "String" + }, + "createdAt": { + "type": "Date" + }, + "lastUsedAt": { + "type": "Date" }, "code": { "type": "String", @@ -87,10 +101,6 @@ "type": "Object", "readAccess": "none" }, - "verifiedAt": { - "type": "Date", - "readAccess": "none" - }, "expiresAt": { "type": "Date", "readAccess": "none" diff --git a/services/api/src/routes/__tests__/signup.js b/services/api/src/routes/__tests__/signup.js new file mode 100644 index 000000000..af789d304 --- /dev/null +++ b/services/api/src/routes/__tests__/signup.js @@ -0,0 +1,266 @@ +const { assertSmsSent } = require('twilio'); +const { assertMailSent } = require('postmark'); +const { request, createUser } = require('../../utils/testing'); +const { assertAuthToken } = require('../../utils/testing/tokens'); +const { User } = require('../../models'); + +describe('POST /signup', () => { + describe('password', () => { + it('should be able to sign up with a password', async () => { + const email = 'foo@bar.com'; + + const response = await request('POST', '/1/signup', { + type: 'password', + firstName: 'Bob', + lastName: 'Johnson', + password: '123password!', + email, + }); + expect(response.status).toBe(200); + + assertMailSent({ + to: email, + }); + + const user = await User.findOne({ + email, + }); + + assertAuthToken(user, response.body.data.token); + + expect(user.email).toBe(email); + expect(user.authTokens.length).toBe(1); + expect(user.emailVerified).toBe(false); + expect(user.authenticators).toMatchObject([ + { + type: 'password', + }, + ]); + + assertMailSent({ + to: user.email, + template: 'welcome', + }); + }); + + it('should error if no password provided', async () => { + const email = 'foo@bar.com'; + + const response = await request('POST', '/1/signup', { + type: 'password', + firstName: 'Bob', + lastName: 'Johnson', + email, + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Password is required.'); + }); + }); + + describe('email otp', () => { + it('should send otp link via email by default', async () => { + const email = 'foo@bar.com'; + + const response = await request('POST', '/1/signup', { + type: 'link', + firstName: 'Bob', + lastName: 'Johnson', + email, + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'link', + transport: 'email', + email, + }, + }); + + assertMailSent({ + to: email, + template: 'otp-signup-link', + }); + + const user = await User.findOne({ + email, + }); + + expect(user.email).toBe(email); + expect(user.emailVerified).toBe(false); + expect(user.authenticators).toMatchObject([ + { + type: 'otp', + }, + ]); + }); + + it('should send just the code if specified', async () => { + const email = 'foo@bar.com'; + + const response = await request('POST', '/1/signup', { + type: 'code', + firstName: 'Bob', + lastName: 'Johnson', + email, + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'email', + email, + }, + }); + + assertMailSent({ + to: email, + template: 'otp-signup-code', + }); + + const user = await User.findOne({ + email, + }); + + expect(user.email).toBe(email); + expect(user.emailVerified).toBe(false); + expect(user.authenticators).toMatchObject([ + { + type: 'otp', + }, + ]); + }); + + it('should error if no email is provided', async () => { + const response = await request('POST', '/1/signup', { + type: 'code', + transport: 'email', + firstName: 'Bob', + lastName: 'Johnson', + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Email is required.'); + }); + }); + + describe('sms otp', () => { + it('should send otp link via sms', async () => { + const phone = '+15551234567'; + + const response = await request('POST', '/1/signup', { + type: 'link', + transport: 'sms', + firstName: 'Bob', + lastName: 'Johnson', + phone, + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'link', + transport: 'sms', + phone, + }, + }); + + assertSmsSent({ + to: phone, + template: 'otp-signup-link', + }); + + const user = await User.findOne({ + phone, + }); + + expect(user.phone).toBe(phone); + expect(user.phoneVerified).toBe(false); + expect(user.authenticators).toMatchObject([ + { + type: 'otp', + }, + ]); + }); + + it('should send just the code if specified', async () => { + const phone = '+15551234567'; + + const response = await request('POST', '/1/signup', { + type: 'code', + transport: 'sms', + firstName: 'Bob', + lastName: 'Johnson', + phone, + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'sms', + phone, + }, + }); + + assertSmsSent({ + to: phone, + template: 'otp-signup-code', + }); + + const user = await User.findOne({ + phone, + }); + + expect(user.phone).toBe(phone); + expect(user.phoneVerified).toBe(false); + expect(user.authenticators).toMatchObject([ + { + type: 'otp', + }, + ]); + }); + }); + + describe('errors', () => { + it('should error if no first name passed', async () => { + const response = await request('POST', '/1/signup', { + lastName: 'Johnson', + password: '123password!', + email: 'foo@bar.com', + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('"firstName" is required.'); + }); + + it('should error if email used', async () => { + const email = 'foo@bar.com'; + + await createUser({ + email, + }); + const response = await request('POST', '/1/signup', { + firstName: 'Bob', + lastName: 'Johnson', + password: '123password!', + email, + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('A user with that email already exists.'); + }); + + it('should error if phone number used', async () => { + const phone = '+15551234567'; + + await createUser({ + phone, + }); + + const response = await request('POST', '/1/signup', { + firstName: 'Bob', + lastName: 'Johnson', + password: '123password!', + email: 'foo@bar.com', + phone, + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('A user with that phone number already exists.'); + }); + }); +}); diff --git a/services/api/src/routes/auth/__tests__/apple.js b/services/api/src/routes/auth/__tests__/apple.js index 8713eb0b6..5f8eb0b1a 100644 --- a/services/api/src/routes/auth/__tests__/apple.js +++ b/services/api/src/routes/auth/__tests__/apple.js @@ -2,20 +2,30 @@ const { createToken } = require('verify-apple-id-token'); const { request, createUser } = require('../../../utils/testing'); const { assertAuthToken } = require('../../../utils/testing/tokens'); const { hasAuthenticator } = require('../../../utils/auth/authenticators'); -const { addAppleAuthenticator } = require('../apple/utils'); +const { upsertAppleAuthenticator } = require('../../../utils/auth/apple'); +const { mockTime, unmockTime } = require('../../../utils/testing/time'); const { User } = require('../../../models'); describe('/1/auth/apple', () => { - describe('POST login', () => { + describe('POST /', () => { it('should verify a token for new user', async () => { + const email = 'foo@bar.com'; const token = createToken({ - email: 'foo@bar.com', + email, }); - const response = await request('POST', '/1/auth/apple/login', { + const response = await request('POST', '/1/auth/apple', { token, + firstName: 'Frank', + lastName: 'Reynolds', }); expect(response.status).toBe(200); - expect(response.body.data.next).toBe('signup'); + expect(response.body.data.result).toBe('signup'); + + const user = await User.findOne({ + email, + }); + + expect(user.email).toBe(email); }); it('should verify a token for an existing user', async () => { @@ -25,21 +35,39 @@ describe('/1/auth/apple', () => { const token = createToken({ email: 'foo@bar.com', }); - const response = await request('POST', '/1/auth/apple/login', { + const response = await request('POST', '/1/auth/apple', { token, }); expect(response.status).toBe(200); assertAuthToken(user, response.body.data.token); + expect(response.body.data.result).toBe('login'); + }); + + it('should not be able to register with an unverified email', async () => { + const token = createToken({ + givenName: 'Frank', + familyName: 'Reynolds', + email: 'foo@bar.com', + email_verified: false, + }); + const response = await request('POST', '/1/auth/apple', { + firstName: 'Frank', + lastName: 'Reynolds', + token, + }); + expect(response.status).toBe(400); }); it('should add authenticator if none', async () => { + mockTime('2020-01-01'); + let user = await createUser({ email: 'foo@bar.com', }); const token = createToken({ email: 'foo@bar.com', }); - const response = await request('POST', '/1/auth/apple/login', { + const response = await request('POST', '/1/auth/apple', { token, }); @@ -48,8 +76,11 @@ describe('/1/auth/apple', () => { expect(user.authenticators).toMatchObject([ { type: 'apple', + createdAt: new Date('2020-01-01'), }, ]); + + unmockTime(); }); it('should not add multiple authenticators', async () => { @@ -60,11 +91,11 @@ describe('/1/auth/apple', () => { email: 'foo@bar.com', }); - await request('POST', '/1/auth/apple/login', { + await request('POST', '/1/auth/apple', { token, }); - await request('POST', '/1/auth/apple/login', { + await request('POST', '/1/auth/apple', { token, }); @@ -73,129 +104,21 @@ describe('/1/auth/apple', () => { }); it('should throw an error on a bad token', async () => { - const response = await request('POST', '/1/auth/apple/login', { + const response = await request('POST', '/1/auth/apple', { token: 'bad', }); expect(response.status).toBe(400); }); }); - describe('POST register', () => { - it('should be able to sign up', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/apple/register', { - firstName: 'Bob', - lastName: 'Johnson', - token, - }); - expect(response.status).toBe(200); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - expect(user).toMatchObject({ - firstName: 'Bob', - lastName: 'Johnson', - email: 'foo@bar.com', - }); - }); - - it('should be able to override provided name', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/apple/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token, - }); - expect(response.status).toBe(200); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - expect(user).toMatchObject({ - firstName: 'Frank', - lastName: 'Reynolds', - email: 'foo@bar.com', - }); - }); - - it('should not be able to override provided email', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/apple/register', { - firstName: 'Frank', - lastName: 'Reynolds', - email: 'bar@foo.com', - token, - }); - expect(response.status).toBe(400); - }); - - it('should not be able to register with an unverified email', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - email_verified: false, - }); - const response = await request('POST', '/1/auth/apple/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token, - }); - expect(response.status).toBe(400); - }); - - it('should throw an error on a bad token', async () => { - const response = await request('POST', '/1/auth/apple/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token: 'bad', - }); - expect(response.status).toBe(400); - }); - - it('should add an authenticator', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/apple/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token, - }); - expect(response.status).toBe(200); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - - expect(user.authenticators).toMatchObject([ - { - type: 'apple', - }, - ]); - }); - }); - - describe('POST enable', () => { + describe('POST /enable', () => { it('should add authenticator', async () => { let user = await createUser({ email: 'foo@bar.com', }); const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', + givenName: 'Frank', + familyName: 'Reynolds', email: 'foo@bar.com', }); const response = await request( @@ -238,7 +161,7 @@ describe('/1/auth/apple', () => { let user = await createUser({ email: 'foo@bar.com', }); - addAppleAuthenticator(user); + upsertAppleAuthenticator(user); await user.save(); const response = await request( diff --git a/services/api/src/routes/auth/__tests__/google.js b/services/api/src/routes/auth/__tests__/google.js index 204ecc558..59fff654e 100644 --- a/services/api/src/routes/auth/__tests__/google.js +++ b/services/api/src/routes/auth/__tests__/google.js @@ -1,244 +1,143 @@ -const { createToken } = require('google-auth-library'); +const { createCode } = require('google-auth-library'); const { request, createUser } = require('../../../utils/testing'); const { assertAuthToken } = require('../../../utils/testing/tokens'); +const { upsertGoogleAuthenticator } = require('../../../utils/auth/google'); const { hasAuthenticator } = require('../../../utils/auth/authenticators'); -const { addGoogleAuthenticator } = require('../google/utils'); +const { mockTime, unmockTime, advanceTime } = require('../../../utils/testing/time'); const { User } = require('../../../models'); describe('/1/auth/google', () => { - describe('POST login', () => { - it('should verify a token for new user', async () => { - const token = createToken({ - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/google/login', { - token, - }); - expect(response.status).toBe(200); - expect(response.body.data.next).toBe('signup'); - }); + describe('POST /', () => { + it('should sign up a new user', async () => { + const email = 'foo@bar.com'; - it('should verify a token for an existing user', async () => { - const user = await createUser({ - email: 'foo@bar.com', - }); - const token = createToken({ - email: 'foo@bar.com', + const code = createCode({ + email, + given_name: 'Frank', + family_name: 'Reynolds', }); - const response = await request('POST', '/1/auth/google/login', { - token, + + const response = await request('POST', '/1/auth/google', { + code, }); expect(response.status).toBe(200); - assertAuthToken(user, response.body.data.token); - }); + expect(response.body.data.result).toBe('signup'); - it('should add authenticator if none', async () => { - let user = await createUser({ - email: 'foo@bar.com', - }); - const token = createToken({ - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/google/login', { - token, + const user = await User.findOne({ + email, }); - expect(response.status).toBe(200); - user = await User.findById(user.id); - expect(user.authenticators).toMatchObject([ - { - type: 'google', - }, - ]); + assertAuthToken(user, response.body.data.token); + expect(user.email).toBe(email); }); - it('should not add multiple authenticators', async () => { + it('should verify a token for an existing user', async () => { + mockTime('2020-01-01'); + let user = await createUser({ email: 'foo@bar.com', + authenticators: [ + { + type: 'google', + }, + ], }); - const token = createToken({ + const code = createCode({ email: 'foo@bar.com', }); + advanceTime(1000); - await request('POST', '/1/auth/google/login', { - token, - }); - - await request('POST', '/1/auth/google/login', { - token, + const response = await request('POST', '/1/auth/google', { + code, }); + expect(response.status).toBe(200); + assertAuthToken(user, response.body.data.token); + expect(response.body.data.result).toBe('login'); user = await User.findById(user.id); - expect(user.authenticators.length).toBe(1); - }); - - it('should throw an error on a bad token', async () => { - const response = await request('POST', '/1/auth/google/login', { - token: 'bad', - }); - expect(response.status).toBe(400); - }); - }); - - describe('POST register', () => { - it('should be able to sign up', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/google/register', { - firstName: 'Bob', - lastName: 'Johnson', - token, - }); - expect(response.status).toBe(200); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - expect(user).toMatchObject({ - firstName: 'Bob', - lastName: 'Johnson', - email: 'foo@bar.com', - }); - }); - it('should be able to override provided name', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/google/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token, - }); - expect(response.status).toBe(200); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - expect(user).toMatchObject({ - firstName: 'Frank', - lastName: 'Reynolds', - email: 'foo@bar.com', + const { lastUsedAt } = user.authenticators.find((authenticator) => { + return authenticator.type === 'google'; }); - }); - it('should not be able to override provided email', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/google/register', { - firstName: 'Frank', - lastName: 'Reynolds', - email: 'bar@foo.com', - token, - }); - expect(response.status).toBe(400); + expect(lastUsedAt).toEqual(new Date('2020-01-01T00:00:01.000Z')); + unmockTime(); }); it('should not be able to register with an unverified email', async () => { - const token = createToken({ + const code = createCode({ givenName: 'Bob', familyName: 'Johnson', email: 'foo@bar.com', email_verified: false, }); - const response = await request('POST', '/1/auth/google/register', { + const response = await request('POST', '/1/auth/google', { firstName: 'Frank', lastName: 'Reynolds', - token, + code, }); expect(response.status).toBe(400); }); - it('should throw an error on a bad token', async () => { - const response = await request('POST', '/1/auth/google/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token: 'bad', - }); - expect(response.status).toBe(400); - }); + it('should add authenticator if none', async () => { + mockTime('2020-01-01'); - it('should add an authenticator', async () => { - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', + let user = await createUser({ email: 'foo@bar.com', }); - const response = await request('POST', '/1/auth/google/register', { - firstName: 'Frank', - lastName: 'Reynolds', - token, - }); - expect(response.status).toBe(200); - const user = await User.findOne({ + const code = createCode({ email: 'foo@bar.com', }); + const response = await request('POST', '/1/auth/google', { + code, + }); + expect(response.status).toBe(200); + user = await User.findById(user.id); expect(user.authenticators).toMatchObject([ { type: 'google', + createdAt: new Date('2020-01-01'), }, ]); + + unmockTime(); }); - }); - describe('POST enable', () => { - it('should add authenticator', async () => { + it('should not add multiple authenticators', async () => { let user = await createUser({ email: 'foo@bar.com', }); - const token = createToken({ - givenName: 'Bob', - familyName: 'Johnson', + const code = createCode({ email: 'foo@bar.com', }); - const response = await request( - 'POST', - '/1/auth/google/enable', - { - token, - }, - { - user, - } - ); - expect(response.status).toBe(200); - expect(response.body.data.id).toBe(user.id); + + await request('POST', '/1/auth/google', { + code, + }); + + await request('POST', '/1/auth/google', { + code, + }); user = await User.findById(user.id); - expect(hasAuthenticator(user, 'google')).toBe(true); + expect(user.authenticators.length).toBe(1); }); - it('should validate token', async () => { - let user = await createUser({ - email: 'foo@bar.com', + it('should throw an error on a bad token', async () => { + const response = await request('POST', '/1/auth/google', { + token: 'bad', }); - const response = await request( - 'POST', - '/1/auth/google/enable', - { - token: 'bad-token', - }, - { - user, - } - ); expect(response.status).toBe(400); }); }); - describe('POST disable', () => { + describe('POST /disable', () => { it('should remove authenticator', async () => { let user = await createUser({ email: 'foo@bar.com', }); - addGoogleAuthenticator(user); + upsertGoogleAuthenticator(user); await user.save(); const response = await request( diff --git a/services/api/src/routes/auth/__tests__/integration.js b/services/api/src/routes/auth/__tests__/integration.js index 552024942..f30771503 100644 --- a/services/api/src/routes/auth/__tests__/integration.js +++ b/services/api/src/routes/auth/__tests__/integration.js @@ -1,6 +1,6 @@ const { assertSmsSent } = require('twilio'); const { assertMailSent } = require('postmark'); -const { getOtp, createOtp } = require('../../../utils/auth/otp'); +const { createOtp } = require('../../../utils/auth/otp'); const { request, createUser } = require('../../../utils/testing'); const { assertAuthToken } = require('../../../utils/testing/tokens'); const { mockTime, unmockTime, advanceTime } = require('../../../utils/testing/time'); @@ -24,9 +24,10 @@ describe('mfa', () => { }); expect(response.status).toBe(200); expect(response.body.data).toEqual({ - next: { - type: 'otp', - phone: '+12223456789', + challenge: { + type: 'code', + transport: 'sms', + phone: user.phone, }, }); @@ -35,7 +36,10 @@ describe('mfa', () => { }); user = await User.findById(user.id); - const code = getOtp(user); + + const { code } = user.authenticators.find((authenticator) => { + return authenticator.type === 'otp'; + }); // First attempt at submitting code failed response = await request('POST', '/1/auth/otp/login', { @@ -69,8 +73,9 @@ describe('mfa', () => { }); expect(response.status).toBe(200); expect(response.body.data).toEqual({ - next: { - type: 'otp', + challenge: { + type: 'code', + transport: 'email', email: user.email, }, }); @@ -80,7 +85,10 @@ describe('mfa', () => { }); user = await User.findById(user.id); - const code = getOtp(user); + + const { code } = user.authenticators.find((authenticator) => { + return authenticator.type === 'otp'; + }); // First attempt at submitting code failed response = await request('POST', '/1/auth/otp/login', { diff --git a/services/api/src/routes/auth/__tests__/otp.js b/services/api/src/routes/auth/__tests__/otp.js index 7a74c6d97..7d621c6da 100644 --- a/services/api/src/routes/auth/__tests__/otp.js +++ b/services/api/src/routes/auth/__tests__/otp.js @@ -1,5 +1,5 @@ const { assertSmsSent, assertSmsCount } = require('twilio'); -const { assertMailSent } = require('postmark'); +const { assertMailSent, assertMailCount } = require('postmark'); const { createOtp } = require('../../../utils/auth/otp'); const { request, createUser } = require('../../../utils/testing'); const { assertAuthToken } = require('../../../utils/testing/tokens'); @@ -7,40 +7,95 @@ const { mockTime, unmockTime, advanceTime } = require('../../../utils/testing/ti const { User } = require('../../../models'); describe('/1/auth/otp', () => { - describe('POST /send-code', () => { - it('should send an otp code via sms', async () => { - const user = await createUser({ - phone: '+12223456789', - }); - const response = await request('POST', '/1/auth/otp/send-code', { - phone: user.phone, + describe('POST /send', () => { + describe('links', () => { + it('should send an otp link via email by default', async () => { + const email = 'foo@bar.com'; + await createUser({ + email, + }); + const response = await request('POST', '/1/auth/otp/send', { + email, + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'link', + transport: 'email', + email, + }, + }); + + assertMailSent({ + to: email, + template: 'otp-login-link', + }); }); - expect(response.status).toBe(204); - assertSmsSent({ - to: user.phone, + it('should send an otp link via sms', async () => { + const phone = '+15551234567'; + await createUser({ + phone, + }); + const response = await request('POST', '/1/auth/otp/send', { + phone, + transport: 'sms', + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'link', + transport: 'sms', + phone, + }, + }); + + assertSmsSent({ + to: phone, + template: 'otp-login-link', + }); }); }); - it('should send an otp code via email', async () => { - const user = await createUser({ - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/otp/send-code', { - email: user.email, - }); - expect(response.status).toBe(204); + describe('raw code', () => { + it('should send a code to sms', async () => { + const phone = '+15551234567'; + await createUser({ + phone, + }); + const response = await request('POST', '/1/auth/otp/send', { + phone, + type: 'code', + transport: 'sms', + }); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'sms', + phone, + }, + }); - assertMailSent({ - to: user.email, + assertSmsSent({ + to: phone, + template: 'otp-login-code', + }); }); }); it('should return empty response without sending if no user exists', async () => { - const response = await request('POST', '/1/auth/otp/send-code', { + const response = await request('POST', '/1/auth/otp/send', { email: 'foo@bar.com', }); - expect(response.status).toBe(204); + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + email: 'foo@bar.com', + transport: 'email', + type: 'link', + }, + }); assertSmsCount(0); }); @@ -49,10 +104,10 @@ describe('/1/auth/otp', () => { let user = await createUser({ phone: '+12223456789', }); - const response = await request('POST', '/1/auth/otp/send-code', { + const response = await request('POST', '/1/auth/otp/send', { phone: '+12223456789', }); - expect(response.status).toBe(204); + expect(response.status).toBe(200); user = await User.findById(user.id); @@ -71,10 +126,10 @@ describe('/1/auth/otp', () => { }); const oldCode = await createOtp(user); - const response = await request('POST', '/1/auth/otp/send-code', { + const response = await request('POST', '/1/auth/otp/send', { phone: '+12223456789', }); - expect(response.status).toBe(204); + expect(response.status).toBe(200); user = await User.findById(user.id); @@ -85,23 +140,32 @@ describe('/1/auth/otp', () => { expect(authenticators[0].code).not.toBe(oldCode); }); - it('should be a test code if the user is a tester', async () => { + it('should return a test code if the user is a tester', async () => { + mockTime('2020-01-01'); + + let response; + let user = await createUser({ email: 'tester@foo.com', isTester: true, }); - const response = await request('POST', '/1/auth/otp/send-code', { + response = await request('POST', '/1/auth/otp/send', { email: 'tester@foo.com', }); - expect(response.status).toBe(204); + expect(response.status).toBe(200); + expect(response.body.data.challenge.code).toBe('111111'); + + response = await request('POST', '/1/auth/otp/login', { + email: user.email, + code: '111111', + }); + + expect(response.status).toBe(200); user = await User.findById(user.id); - expect(user.authenticators).toMatchObject([ - { - type: 'otp', - code: '111111', - }, - ]); + assertMailCount(0); + + unmockTime(); }); }); @@ -285,19 +349,4 @@ describe('/1/auth/otp', () => { }); }); }); - - describe('POST /register', () => { - it('should create a new user without a password', async () => { - const response = await request('POST', '/1/auth/otp/register', { - firstName: 'Frank', - lastName: 'Reynolds', - email: 'foo@bar.com', - }); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - expect(response.status).toBe(200); - assertAuthToken(user, response.body.data.token); - }); - }); }); diff --git a/services/api/src/routes/auth/__tests__/passkey.js b/services/api/src/routes/auth/__tests__/passkey.js index e4cece0aa..d324a8f7c 100644 --- a/services/api/src/routes/auth/__tests__/passkey.js +++ b/services/api/src/routes/auth/__tests__/passkey.js @@ -1,5 +1,7 @@ const { request, createUser } = require('../../../utils/testing'); +const { createPasskeyToken } = require('../../../utils/auth/tokens'); const { assertAuthToken } = require('../../../utils/testing/tokens'); +const { mockTime, unmockTime } = require('../../../utils/testing/time'); const { User } = require('../../../models'); function getPasskey() { @@ -13,152 +15,51 @@ function getPasskey() { } describe('/1/auth/passkeys', () => { - describe('POST /login-generate', () => { - it('should return authentication options for existing user', async () => { - const user = await createUser({ - authenticators: [getPasskey()], - }); - - const response = await request('POST', '/1/auth/passkey/login-generate', { - email: user.email, - }); + describe('POST /generate-login', () => { + it('should return authentication options', async () => { + const response = await request('POST', '/1/auth/passkey/generate-login', {}); expect(response.status).toBe(200); - expect(response.body.data).toEqual({ - rpId: 'rpID', + expect(response.body.data.options).toEqual({ + rpID: 'rpID', challenge: 'challenge', - allowCredentials: [ - { - id: 'id', - type: 'public-key', - transports: ['hybrid', 'internal'], - }, - ], + allowCredentials: [], userVerification: 'preferred', timeout: 60000, }); }); - - it('should error for non-existing user', async () => { - const response = await request('POST', '/1/auth/passkey/login-generate', { - email: 'foo@bar.com', - }); - expect(response.status).toBe(404); - }); - - it('should error if existing user does not have a passkey', async () => { - const user = await createUser(); - const response = await request('POST', '/1/auth/passkey/login-generate', { - email: user.email, - }); - expect(response.status).toBe(400); - expect(response.body.error.message).toBe('No passkey set.'); - }); }); - describe('POST /login-verify', () => { + describe('POST /verify-login', () => { it('should verify a good response', async () => { - const user = await createUser({ - authenticators: [getPasskey()], - }); - - const response = await request('POST', '/1/auth/passkey/login-verify', { - email: user.email, - response: { - type: 'good', - }, - }); - expect(response.status).toBe(200); - assertAuthToken(user, response.body.data.token); - }); - - it('should error on a bad response', async () => { - const user = await createUser({ + mockTime('2020-01-01'); + let user = await createUser({ authenticators: [getPasskey()], }); - const response = await request('POST', '/1/auth/passkey/login-verify', { - email: user.email, - response: { - type: 'bad', - }, - }); - expect(response.status).toBe(400); - }); - - it('should error if user does not exist', async () => { - const response = await request('POST', '/1/auth/passkey/login-verify', { - email: 'foo@bar.com', - response: { - type: 'good', - }, + const token = createPasskeyToken({ + challenge: 'challenge', }); - expect(response.status).toBe(400); - }); - it('should error if user does not have passkey', async () => { - const user = await createUser(); - - const response = await request('POST', '/1/auth/passkey/login-verify', { - email: user.email, + const response = await request('POST', '/1/auth/passkey/verify-login', { + token, response: { + id: 'id', type: 'good', }, }); - expect(response.status).toBe(400); - }); - }); - describe('POST /register-generate', () => { - it('should return register options', async () => { - const response = await request('POST', '/1/auth/passkey/register-generate', { - firstName: 'Frank', - lastName: 'Reynolds', - email: 'foo@bar.com', - }); expect(response.status).toBe(200); + assertAuthToken(user, response.body.data.token); - const user = await User.findOne({ - email: 'foo@bar.com', - }); - - expect(response.body.data).toMatchObject({ - challenge: 'challenge', - user: { - id: 'id', - name: user.name, - displayName: user.name, - }, - timeout: 60000, - }); - }); - - it('should error error if user exists', async () => { - await createUser({ - email: 'foo@bar.com', - }); - const response = await request('POST', '/1/auth/passkey/register-generate', { - firstName: 'Frank', - lastName: 'Reynolds', - email: 'foo@bar.com', - }); - expect(response.status).toBe(400); - }); - }); - - describe('POST /register-verify', () => { - it('should verify a good response', async () => { - const user = await createUser({ - authenticators: [getPasskey()], - }); + user = await User.findById(user.id); - const response = await request('POST', '/1/auth/passkey/register-verify', { - email: user.email, - response: { - type: 'good', + expect(user.authenticators.toObject()).toMatchObject([ + { + type: 'passkey', + lastUsedAt: new Date(), }, - }); - expect(response.status).toBe(200); - assertAuthToken(user, response.body.data.token); + ]); + unmockTime(); }); it('should error on a bad response', async () => { @@ -166,7 +67,7 @@ describe('/1/auth/passkeys', () => { authenticators: [getPasskey()], }); - const response = await request('POST', '/1/auth/passkey/register-verify', { + const response = await request('POST', '/1/auth/passkey/verify-login', { email: user.email, response: { type: 'bad', @@ -176,7 +77,7 @@ describe('/1/auth/passkeys', () => { }); it('should error if user does not exist', async () => { - const response = await request('POST', '/1/auth/passkey/register-verify', { + const response = await request('POST', '/1/auth/passkey/verify-login', { email: 'foo@bar.com', response: { type: 'good', @@ -188,7 +89,7 @@ describe('/1/auth/passkeys', () => { it('should error if user does not have passkey', async () => { const user = await createUser(); - const response = await request('POST', '/1/auth/passkey/register-verify', { + const response = await request('POST', '/1/auth/passkey/verify-login', { email: user.email, response: { type: 'good', @@ -198,76 +99,54 @@ describe('/1/auth/passkeys', () => { }); }); - describe('POST /enable-generate', () => { + describe('POST /generate-new', () => { it('should return authentication options for authenticated user', async () => { - const user = await createUser(); + let user = await createUser(); const response = await request( 'POST', - '/1/auth/passkey/enable-generate', + '/1/auth/passkey/generate-new', {}, { user, } ); expect(response.status).toBe(200); - expect(response.body.data).toMatchObject({ + expect(response.body.data.options).toMatchObject({ challenge: 'challenge', user: { id: 'id', - name: user.name, + name: user.email, }, timeout: 60000, }); - }); - - it('should clear existing passkeys', async () => { - const passkey = getPasskey(); - let user = await createUser({ - authenticators: [passkey], - }); - const response = await request( - 'POST', - '/1/auth/passkey/enable-generate', - {}, - { - user, - } - ); - expect(response.status).toBe(200); user = await User.findById(user.id); - expect(user.authenticators.length).toBe(1); - expect(user.authenticators[0].toObject()).toMatchObject({ - type: 'passkey', - info: { - user: { - id: 'id', - name: user.name, - displayName: user.name, - }, - }, - }); + expect(user.authenticators.length).toBe(0); }); it('should error without authentication', async () => { - const response = await request('POST', '/1/auth/passkey/enable-generate', { + const response = await request('POST', '/1/auth/passkey/generate-new', { email: 'foo@bar.com', }); expect(response.status).toBe(401); }); }); - describe('POST /enable-verify', () => { + describe('POST /verify-new', () => { it('should verify a good response', async () => { - const user = await createUser({ - authenticators: [getPasskey()], + mockTime('2020-01-01'); + let user = await createUser(); + + const token = createPasskeyToken({ + challenge: 'challenge', }); const response = await request( 'POST', - '/1/auth/passkey/enable-verify', + '/1/auth/passkey/verify-new', { + token, response: { type: 'good', }, @@ -280,66 +159,99 @@ describe('/1/auth/passkeys', () => { expect(response.body.data).toMatchObject({ id: user.id, }); + + user = await User.findById(user.id); + expect(user.authenticators.toObject()).toMatchObject([ + { + type: 'passkey', + name: 'Passkey 1', + createdAt: new Date('2020-01-01'), + info: { + id: 'id', + }, + }, + ]); + unmockTime(); }); - it('should error on a bad response', async () => { - const user = await createUser({ - authenticators: [getPasskey()], + it('should derive name from platform', async () => { + let user = await createUser(); + + const token = createPasskeyToken({ + challenge: 'challenge', }); - const response = await request( + await request( 'POST', - '/1/auth/passkey/enable-verify', + '/1/auth/passkey/verify-new', { - email: user.email, + token, response: { - type: 'bad', + type: 'good', }, }, { user, + headers: { + 'sec-ch-ua-platform': '"macOS"', + }, } ); - expect(response.status).toBe(400); - }); - it('should error if not authenticated', async () => { - const response = await request('POST', '/1/auth/passkey/enable-verify', { - response: { - type: 'good', + user = await User.findById(user.id); + expect(user.authenticators.toObject()).toMatchObject([ + { + type: 'passkey', + name: 'macOS', }, - }); - expect(response.status).toBe(401); + ]); }); - it('should error if user does not have passkey', async () => { - const user = await createUser(); + it('should error on a bad response', async () => { + const token = createPasskeyToken({ + challenge: 'challenge', + }); + const user = await createUser({ + authenticators: [getPasskey()], + }); const response = await request( 'POST', - '/1/auth/passkey/enable-verify', + '/1/auth/passkey/verify-new', { + token, response: { - type: 'good', + type: 'bad', }, }, { user, } ); - expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Bad registration response.'); + }); + + it('should error if not authenticated', async () => { + const response = await request('POST', '/1/auth/passkey/verify-new', { + response: { + type: 'good', + }, + }); + expect(response.status).toBe(401); }); }); - describe('POST /disable', () => { - it('should remove passkey authenticators', async () => { + describe('DELETE /:id', () => { + it('should delete a passkey', async () => { let user = await createUser({ authenticators: [getPasskey()], }); + const id = user.authenticators[0].id; + const response = await request( - 'POST', - '/1/auth/passkey/disable', + 'DELETE', + `/1/auth/passkey/${id}`, {}, { user, @@ -348,11 +260,11 @@ describe('/1/auth/passkeys', () => { expect(response.status).toBe(200); user = await User.findById(user.id); - expect(user.authenticators).toEqual([]); + expect(user.authenticators.toObject()).toEqual([]); }); it('should error if not authenticated', async () => { - const response = await request('POST', '/1/auth/passkey/disable', {}); + const response = await request('DELETE', '/1/auth/passkey/id', {}); expect(response.status).toBe(401); }); }); diff --git a/services/api/src/routes/auth/__tests__/password.js b/services/api/src/routes/auth/__tests__/password.js index e7c972077..d069de8ac 100644 --- a/services/api/src/routes/auth/__tests__/password.js +++ b/services/api/src/routes/auth/__tests__/password.js @@ -6,6 +6,7 @@ const { mockTime, unmockTime, advanceTime } = require('../../../utils/testing/ti const { createAuthToken, createTemporaryAuthToken } = require('../../../utils/auth/tokens'); const { verifyPassword } = require('../../../utils/auth/password'); const { assertAuthToken } = require('../../../utils/testing/tokens'); +const { getAuthenticator } = require('../../../utils/auth/authenticators'); const { User } = require('../../../models'); function getJti(token) { @@ -42,9 +43,12 @@ describe('/1/auth', () => { }); expect(response.status).toBe(200); - expect(response.body.data.next).toEqual({ - type: 'otp', - phone: '+12312312422', + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'sms', + phone: user.phone, + }, }); assertSmsSent({ @@ -64,9 +68,12 @@ describe('/1/auth', () => { }); expect(response.status).toBe(200); - expect(response.body.data.next).toEqual({ - type: 'otp', - email: user.email, + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'email', + email: user.email, + }, }); assertMailSent({ @@ -74,6 +81,48 @@ describe('/1/auth', () => { }); }); + it('should challenge with totp via authenticator', async () => { + const user = await createUser({ + password: '123password!', + mfaMethod: 'totp', + }); + + const response = await request('POST', '/1/auth/password/login', { + email: user.email, + password: '123password!', + }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'authenticator', + email: user.email, + }, + }); + }); + + it('should verify the password on challenge', async () => { + mockTime('2020-01-01T00:00:00.000Z'); + + let user = await createUser({ + password: '123password!', + mfaMethod: 'totp', + }); + + advanceTime(1000); + + await request('POST', '/1/auth/password/login', { + email: user.email, + password: '123password!', + }); + + user = await User.findById(user); + const authenticator = getAuthenticator(user, 'password'); + expect(authenticator.lastUsedAt).toEqual(new Date('2020-01-01T00:00:01.000Z')); + unmockTime(); + }); + it('should store the new token payload on the user', async () => { mockTime('2020-01-01T00:00:00.000Z'); const password = '123password!'; @@ -109,9 +158,12 @@ describe('/1/auth', () => { password, }); expect(response.status).toBe(200); - expect(response.body.data.next).toEqual({ - type: 'otp', - phone: '+12223456789', + expect(response.body.data).toEqual({ + challenge: { + type: 'code', + transport: 'sms', + phone: user.phone, + }, }); assertSmsSent({ @@ -243,141 +295,6 @@ describe('/1/auth', () => { }); }); - describe('POST /register', () => { - it('should be able to register with email', async () => { - const email = 'foo@bar.com'; - - const response = await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email, - }); - expect(response.status).toBe(200); - - assertMailSent({ - to: email, - }); - - const user = await User.findOne({ - email, - }); - - assertAuthToken(user, response.body.data.token); - expect(user.email).toBe(email); - }); - - it('should error on duplicated emails', async () => { - await createUser({ - email: 'foo@bar.com', - }); - - const response = await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email: 'foo@bar.com', - }); - - expect(response.status).toBe(400); - expect(response.body.error.message).toBe('A user with that email already exists.'); - }); - - it('should error on duplicated emails with different casing', async () => { - await createUser({ - email: 'foo@bar.com', - }); - - const response = await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email: 'FOO@BAR.COM', - }); - - expect(response.status).toBe(400); - expect(response.body.error.message).toBe('A user with that email already exists.'); - }); - - it('should not hint at existing emails on production', async () => { - process.env.ENV_NAME = 'production'; - await createUser({ - email: 'foo@bar.com', - }); - - const response = await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email: 'foo@bar.com', - }); - - expect(response.status).toBe(400); - expect(response.body.error.message).toBe('An error occurred.'); - - process.env.ENV_NAME = 'test'; - }); - - it('should error on duplicated phone number', async () => { - await createUser({ - phone: '+12223456789', - }); - - const response = await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email: 'foo@bar.com', - phone: '+12223456789', - }); - - expect(response.status).toBe(400); - expect(response.body.error.message).toBe('A user with that phone number already exists.'); - }); - - it('should not hint at existing phone numbers on production', async () => { - process.env.ENV_NAME = 'production'; - await createUser({ - phone: '+12223456789', - }); - - const response = await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email: 'foo@bar.com', - phone: '+12223456789', - }); - - expect(response.status).toBe(400); - expect(response.body.error.message).toBe('An error occurred.'); - - process.env.ENV_NAME = 'test'; - }); - - it('should add to authenticators', async () => { - mockTime('2020-01-01'); - await request('POST', '/1/auth/password/register', { - firstName: 'Bob', - lastName: 'Johnson', - password: '123password!', - email: 'foo@bar.com', - }); - - const user = await User.findOne({ - email: 'foo@bar.com', - }); - - expect(user.authenticators).toMatchObject([ - { - type: 'password', - verifiedAt: new Date('2020-01-01'), - }, - ]); - unmockTime(); - }); - }); - describe('POST /request', () => { it('should send an email to the registered user', async () => { const user = await createUser(); diff --git a/services/api/src/routes/auth/__tests__/totp.js b/services/api/src/routes/auth/__tests__/totp.js index a400d9005..d9df2c4d9 100644 --- a/services/api/src/routes/auth/__tests__/totp.js +++ b/services/api/src/routes/auth/__tests__/totp.js @@ -2,12 +2,14 @@ const speakeasy = require('speakeasy'); const { request, createUser } = require('../../../utils/testing'); const { assertAuthToken } = require('../../../utils/testing/tokens'); const { mockTime, unmockTime, advanceTime } = require('../../../utils/testing/time'); -const { createSecret, enableTotp } = require('../totp/utils'); +const { createSecret, enableTotp } = require('../../../utils/auth/totp'); const { User } = require('../../../models'); describe('/1/auth/totp', () => { describe('POST /login', () => { it('should verify a code', async () => { + mockTime('2020-01-01T00:00:00.000Z'); + let user = await createUser(); const secret = createSecret(); @@ -18,6 +20,8 @@ describe('/1/auth/totp', () => { secret, }); + advanceTime(1000); + const response = await request( 'POST', '/1/auth/totp/login', @@ -31,6 +35,16 @@ describe('/1/auth/totp', () => { ); expect(response.status).toBe(200); assertAuthToken(user, response.body.data.token); + + user = await User.findById(user.id); + expect(user.authenticators.toObject()).toMatchObject([ + { + type: 'totp', + lastUsedAt: new Date('2020-01-01T00:00:01.000Z'), + }, + ]); + + unmockTime(); }); it('should throttle logins', async () => { @@ -98,6 +112,8 @@ describe('/1/auth/totp', () => { describe('POST /enable', () => { it('should enable a totp authenticator', async () => { + mockTime('2020-01-01'); + let user = await createUser(); const secret = createSecret(); const code = speakeasy.totp({ @@ -118,14 +134,15 @@ describe('/1/auth/totp', () => { expect(response.status).toBe(200); user = await User.findById(user.id); - expect(user.authenticators).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'totp', - secret, - }), - ]) - ); + expect(user.authenticators.toObject()).toMatchObject([ + { + type: 'totp', + secret, + createdAt: new Date('2020-01-01'), + }, + ]); + + unmockTime(); }); it('should request authentication', async () => { @@ -152,6 +169,7 @@ describe('/1/auth/totp', () => { expect(response.status).toBe(200); user = await User.findById(user.id); + expect(user.mfaMethod).toBe('none'); expect(user.authenticators).not.toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/services/api/src/routes/auth/apple.js b/services/api/src/routes/auth/apple.js new file mode 100644 index 000000000..253ee68b3 --- /dev/null +++ b/services/api/src/routes/auth/apple.js @@ -0,0 +1,113 @@ +const Router = require('@koa/router'); +const yd = require('@bedrockio/yada'); +const { validateBody } = require('../../utils/middleware/validate'); +const { authenticate } = require('../../utils/middleware/authenticate'); + +const { login, createAuthToken } = require('../../utils/auth'); +const { verifyToken, upsertAppleAuthenticator, removeAppleAuthenticator } = require('../../utils/auth/apple'); +const { User, AuditEntry } = require('../../models'); + +const router = new Router(); + +router + .post( + '/', + validateBody({ + token: yd.string().required(), + firstName: yd.string(), + lastName: yd.string(), + }), + async (ctx) => { + const { token: appleToken, firstName, lastName } = ctx.request.body; + + let payload; + try { + payload = await verifyToken(appleToken); + } catch (error) { + ctx.throw(400, error); + } + + let user = await User.findOne({ + email: payload.email, + }); + + let token; + let result; + + if (user) { + token = await login(ctx, user, { + message: 'Logged in with Apple', + }); + + result = 'login'; + } else { + try { + user = await User.create({ + ...payload, + firstName, + lastName, + }); + } catch (err) { + ctx.throw('Signup failed. Remove your Apple registration.'); + } + + token = createAuthToken(ctx, user); + + await AuditEntry.append('Signed Up with Apple', { + ctx, + actor: user, + category: 'auth', + }); + + result = 'signup'; + } + + upsertAppleAuthenticator(user); + await user.save(); + + ctx.body = { + data: { + token, + result, + }, + }; + } + ) + .use(authenticate()) + .post( + '/enable', + validateBody({ + token: yd.string().required(), + }), + async (ctx) => { + const { token } = ctx.request.body; + const { authUser } = ctx.state; + + try { + await verifyToken(token); + upsertAppleAuthenticator(authUser); + await authUser.save(); + } catch (error) { + ctx.throw(400, error); + } + + ctx.body = { + data: authUser, + }; + } + ) + .post('/disable', async (ctx) => { + const { authUser } = ctx.state; + // Note that AppleId allows for revoking tokens, however this does + // not seem to remove it from the "Sign in with Apple" list or have + // any effect on subsequent logins, so skipping this step and simply + // remove the authenticator. + removeAppleAuthenticator(authUser); + await authUser.save(); + + ctx.body = { + data: authUser, + }; + }); + +module.exports = router; diff --git a/services/api/src/routes/auth/apple/index.js b/services/api/src/routes/auth/apple/index.js deleted file mode 100644 index 7e3abeb90..000000000 --- a/services/api/src/routes/auth/apple/index.js +++ /dev/null @@ -1,123 +0,0 @@ -const Router = require('@koa/router'); -const yd = require('@bedrockio/yada'); -const { validateBody } = require('../../../utils/middleware/validate'); -const { authenticate } = require('../../../utils/middleware/authenticate'); - -const { login, register, signupValidation } = require('../../../utils/auth'); -const { User } = require('../../../models'); - -const { verifyToken, addAppleAuthenticator, removeAppleAuthenticator } = require('./utils'); - -const router = new Router(); - -router - .post( - '/login', - validateBody({ - token: yd.string().required(), - }), - async (ctx) => { - const { token } = ctx.request.body; - - let payload; - try { - payload = await verifyToken(token); - } catch (error) { - ctx.throw(400, error); - } - - const user = await User.findOne({ - email: payload.email, - }); - - if (user) { - addAppleAuthenticator(user); - ctx.body = { - data: { - token: await login(ctx, user), - }, - }; - } else { - ctx.body = { - data: { - next: 'signup', - }, - }; - } - } - ) - .post( - '/register', - validateBody( - signupValidation - .append({ - token: yd.string().required(), - }) - .omit('email') - ), - async (ctx) => { - const { token, ...rest } = ctx.request.body; - - let email; - try { - const payload = await verifyToken(token); - email = payload.email; - } catch (error) { - ctx.throw(400, error); - } - - try { - const user = new User({ - ...rest, - email, - }); - addAppleAuthenticator(user); - - ctx.body = { - data: { - token: await register(ctx, user), - }, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) - .use(authenticate()) - .post( - '/enable', - validateBody({ - token: yd.string().required(), - }), - async (ctx) => { - const { token } = ctx.request.body; - const { authUser } = ctx.state; - - try { - await verifyToken(token); - addAppleAuthenticator(authUser); - await authUser.save(); - } catch (error) { - ctx.throw(400, error); - } - - ctx.body = { - data: authUser, - }; - } - ) - .post('/disable', async (ctx) => { - const { authUser } = ctx.state; - // Note that AppleId allows for revoking tokens, however this does - // not seem to remove it from the "Sign in with Apple" list or have - // any effect on subsequent logins, so skipping this step and simply - // remove the authenticator. - removeAppleAuthenticator(authUser); - await authUser.save(); - - ctx.body = { - data: authUser, - }; - }); - -module.exports = router; diff --git a/services/api/src/routes/auth/google.js b/services/api/src/routes/auth/google.js new file mode 100644 index 000000000..4f34aa8f1 --- /dev/null +++ b/services/api/src/routes/auth/google.js @@ -0,0 +1,80 @@ +const Router = require('@koa/router'); +const yd = require('@bedrockio/yada'); +const { validateBody } = require('../../utils/middleware/validate'); +const { authenticate } = require('../../utils/middleware/authenticate'); + +const { login, createAuthToken } = require('../../utils/auth'); +const { verifyToken, upsertGoogleAuthenticator, removeGoogleAuthenticator } = require('../../utils/auth/google'); + +const { User, AuditEntry } = require('../../models'); + +const router = new Router(); + +router + .post( + '/', + validateBody({ + code: yd.string().required(), + }), + async (ctx) => { + const { code } = ctx.request.body; + + let payload; + try { + payload = await verifyToken(code); + } catch (error) { + ctx.throw(400, error); + } + + let user = await User.findOne({ + email: payload.email, + }); + + let token; + let result; + + if (user) { + token = await login(ctx, user, { + message: 'Logged in with Google', + }); + + result = 'login'; + } else { + user = await User.create({ + ...payload, + }); + + token = createAuthToken(ctx, user); + + await AuditEntry.append('Signed Up with Google', { + ctx, + actor: user, + category: 'auth', + }); + + result = 'signup'; + } + + upsertGoogleAuthenticator(user); + await user.save(); + + ctx.body = { + data: { + token, + result, + }, + }; + } + ) + .use(authenticate()) + .post('/disable', async (ctx) => { + const { authUser } = ctx.state; + removeGoogleAuthenticator(authUser); + await authUser.save(); + + ctx.body = { + data: authUser, + }; + }); + +module.exports = router; diff --git a/services/api/src/routes/auth/google/index.js b/services/api/src/routes/auth/google/index.js deleted file mode 100644 index a9cf96059..000000000 --- a/services/api/src/routes/auth/google/index.js +++ /dev/null @@ -1,125 +0,0 @@ -const Router = require('@koa/router'); -const yd = require('@bedrockio/yada'); -const { validateBody } = require('../../../utils/middleware/validate'); -const { authenticate } = require('../../../utils/middleware/authenticate'); - -const { login, register, signupValidation } = require('../../../utils/auth'); -const { User } = require('../../../models'); - -const { verifyToken, addGoogleAuthenticator, removeGoogleAuthenticator } = require('./utils'); - -const router = new Router(); - -router - .post( - '/login', - validateBody({ - token: yd.string().required(), - }), - async (ctx) => { - const { token } = ctx.request.body; - - let payload; - try { - payload = await verifyToken(token); - } catch (error) { - ctx.throw(400, error); - } - - const user = await User.findOne({ - email: payload.email, - }); - - if (user) { - addGoogleAuthenticator(user); - ctx.body = { - data: { - token: await login(ctx, user), - }, - }; - } else { - const { firstName, lastName } = payload; - ctx.body = { - data: { - next: 'signup', - body: { - firstName, - lastName, - }, - }, - }; - } - } - ) - .post( - '/register', - validateBody( - signupValidation - .append({ - token: yd.string().required(), - }) - .omit('email') - ), - async (ctx) => { - const { token, ...rest } = ctx.request.body; - - let email; - try { - const payload = await verifyToken(token); - email = payload.email; - } catch (error) { - ctx.throw(400, error); - } - - try { - const user = new User({ - ...rest, - email, - }); - addGoogleAuthenticator(user); - - ctx.body = { - data: { - token: await register(ctx, user), - }, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) - .use(authenticate()) - .post( - '/enable', - validateBody({ - token: yd.string().required(), - }), - async (ctx) => { - const { token } = ctx.request.body; - const { authUser } = ctx.state; - - try { - await verifyToken(token); - } catch (error) { - ctx.throw(400, error); - } - - addGoogleAuthenticator(authUser); - await authUser.save(); - - ctx.body = { - data: authUser, - }; - } - ) - .post('/disable', async (ctx) => { - const { authUser } = ctx.state; - removeGoogleAuthenticator(authUser); - await authUser.save(); - - ctx.body = { - data: authUser, - }; - }); - -module.exports = router; diff --git a/services/api/src/routes/auth/otp.js b/services/api/src/routes/auth/otp.js new file mode 100644 index 000000000..f6b0af778 --- /dev/null +++ b/services/api/src/routes/auth/otp.js @@ -0,0 +1,94 @@ +const Router = require('@koa/router'); +const yd = require('@bedrockio/yada'); +const { validateBody } = require('../../utils/middleware/validate'); + +const { sendOtp } = require('../../utils/auth/otp'); +const { verifyOtp } = require('../../utils/auth/otp'); +const { login, verifyLoginAttempts } = require('../../utils/auth'); +const { verifyRecentPassword } = require('../../utils/auth/password'); + +const { AuditEntry } = require('../../models'); +const { findUser } = require('./utils'); + +const router = new Router(); + +router + .post( + '/send', + validateBody({ + type: yd.string().allow('link', 'code').default('link'), + transport: yd.string().allow('email', 'sms').default('email'), + email: yd.string().email(), + phone: yd.string().phone(), + }), + async (ctx) => { + const { body } = ctx.request; + const user = await findUser(ctx); + + const challenge = await sendOtp(user, { + ...body, + phase: 'login', + }); + + ctx.body = { + data: { + challenge, + }, + }; + } + ) + .post( + '/login', + validateBody({ + code: yd.string().length(6).required(), + email: yd.string().email(), + phone: yd.string().phone(), + }), + async (ctx) => { + const { code, email, phone } = ctx.request.body; + const user = await findUser(ctx); + + if (!user) { + ctx.throw(400, 'User not found.'); + } + + try { + await verifyLoginAttempts(user, ctx); + } catch (error) { + await user.save(); + ctx.throw(401, error); + } + + try { + verifyOtp(user, code); + } catch (error) { + await user.save(); + await AuditEntry.append('Incorrect OTP', { + ctx, + actor: user, + category: 'security', + }); + ctx.throw(401, error); + } + + try { + await verifyRecentPassword(user); + } catch (error) { + ctx.throw(401, error); + } + + if (email) { + user.emailVerified = true; + } else if (phone) { + user.phoneVerified = true; + } + + ctx.body = { + data: { + token: await login(ctx, user), + }, + }; + } + ); + +module.exports = router; diff --git a/services/api/src/routes/auth/otp/index.js b/services/api/src/routes/auth/otp/index.js deleted file mode 100644 index 353887046..000000000 --- a/services/api/src/routes/auth/otp/index.js +++ /dev/null @@ -1,127 +0,0 @@ -const Router = require('@koa/router'); -const yd = require('@bedrockio/yada'); -const { validateBody } = require('../../../utils/middleware/validate'); - -const { login, register, signupValidation, verifyLoginAttempts } = require('../../../utils/auth'); -const { verifyRecentPassword } = require('../../../utils/auth/password'); -const { createOtp, verifyOtp } = require('../../../utils/auth/otp'); - -const { mailer, sms } = require('../../../utils/messaging'); -const { User, AuditEntry } = require('../../../models'); - -const router = new Router(); - -async function getUser(ctx) { - const { phone, email } = ctx.request.body; - - let query; - if (phone) { - query = { phone }; - } else if (email) { - query = { email }; - } else { - ctx.throw(400, 'Phone or email is required.'); - } - - return await User.findOne(query); -} - -router - .post( - '/send-code', - validateBody({ - email: yd.string().email(), - phone: yd.string().phone(), - }), - async (ctx) => { - const { email, phone } = ctx.request.body; - const user = await getUser(ctx); - - // If no user continue on as if code was sent. - if (user) { - const code = await createOtp(user); - if (phone) { - await sms.sendMessage({ - to: phone, - template: 'otp', - code, - }); - } else if (email) { - await mailer.sendMail({ - to: email, - template: 'otp', - code, - }); - } - } - - ctx.status = 204; - } - ) - .post( - '/login', - validateBody({ - code: yd.string().length(6), - email: yd.string().email(), - phone: yd.string().phone(), - }), - async (ctx) => { - const { code, email, phone } = ctx.request.body; - const user = await getUser(ctx); - - if (!user) { - ctx.throw(400, 'User not found.'); - } - - try { - await verifyLoginAttempts(user, ctx); - } catch (error) { - await user.save(); - ctx.throw(401, error); - } - - try { - verifyOtp(user, code); - } catch (error) { - await user.save(); - await AuditEntry.append('Incorrect Code', { - ctx, - actor: user, - category: 'security', - }); - ctx.throw(401, error); - } - - try { - await verifyRecentPassword(user); - } catch (error) { - ctx.throw(401, error); - } - - if (email) { - user.emailVerified = true; - } else if (phone) { - user.phoneVerified = true; - } - - ctx.body = { - data: { - token: await login(ctx, user), - }, - }; - } - ) - .post('/register', validateBody(signupValidation.omit('password')), async (ctx) => { - try { - const user = new User(ctx.request.body); - ctx.body = { - data: { - token: await register(ctx, user), - }, - }; - } catch (error) { - ctx.throw(400, error); - } - }); - -module.exports = router; diff --git a/services/api/src/routes/auth/passkey.js b/services/api/src/routes/auth/passkey.js new file mode 100644 index 000000000..7590f4984 --- /dev/null +++ b/services/api/src/routes/auth/passkey.js @@ -0,0 +1,108 @@ +const Router = require('@koa/router'); +const yd = require('@bedrockio/yada'); + +const { validateBody } = require('../../utils/middleware/validate'); +const { authenticate } = require('../../utils/middleware/authenticate'); + +const { login } = require('../../utils/auth'); + +const { + generateRegistrationOptions, + generateAuthenticationOptions, + authenticatePasskeyResponse, + registerNewPasskey, + removePasskey, +} = require('../../utils/auth/passkey'); + +const router = new Router(); + +router + .post('/generate-login', async (ctx) => { + try { + ctx.body = { + data: await generateAuthenticationOptions(), + }; + } catch (error) { + ctx.throw(400, error); + } + }) + .post( + '/verify-login', + validateBody({ + token: yd.string().required(), + response: yd.object().required(), + }), + async (ctx) => { + const { token, response } = ctx.request.body; + + try { + const user = await authenticatePasskeyResponse({ + token, + response, + }); + + ctx.body = { + data: { + token: await login(ctx, user), + }, + }; + } catch (error) { + ctx.throw(400, error); + } + } + ) + .use(authenticate()) + .post('/generate-new', async (ctx) => { + const { authUser } = ctx.state; + + try { + const options = await generateRegistrationOptions(authUser); + await authUser.save(); + ctx.body = { + data: options, + }; + } catch (error) { + ctx.throw(400, error); + } + }) + .post( + '/verify-new', + validateBody({ + token: yd.string().required(), + response: yd.object().required(), + }), + async (ctx) => { + const { authUser } = ctx.state; + const { token, response } = ctx.request.body; + + try { + await registerNewPasskey(authUser, { + ctx, + token, + response, + }); + + ctx.body = { + data: authUser, + }; + } catch (error) { + ctx.throw(400, error); + } + } + ) + .delete('/:id', async (ctx) => { + const { id } = ctx.params; + + if (!id) { + ctx.throw(400, 'No id passed.'); + } + const { authUser } = ctx.state; + removePasskey(authUser, id); + await authUser.save(); + + ctx.body = { + data: authUser, + }; + }); + +module.exports = router; diff --git a/services/api/src/routes/auth/passkey/index.js b/services/api/src/routes/auth/passkey/index.js deleted file mode 100644 index 0ca2b1d4c..000000000 --- a/services/api/src/routes/auth/passkey/index.js +++ /dev/null @@ -1,170 +0,0 @@ -const Router = require('@koa/router'); -const yd = require('@bedrockio/yada'); - -const { validateBody } = require('../../../utils/middleware/validate'); -const { authenticate } = require('../../../utils/middleware/authenticate'); - -const { login, register, signupValidation } = require('../../../utils/auth'); -const { User } = require('../../../models'); - -const { - generateRegistrationOptions, - verifyRegistrationResponse, - generateAuthenticationOptions, - verifyAuthenticationResponse, - removePasskey, -} = require('./utils'); - -const router = new Router(); - -router - .post( - '/login-generate', - validateBody({ - email: yd.string().email().required(), - }), - async (ctx) => { - const { email } = ctx.request.body; - const user = await User.findOne({ email }); - - if (!user) { - ctx.throw(404, 'No user exists for that email.'); - } - - try { - const options = await generateAuthenticationOptions(user); - await user.save(); - - ctx.body = { - data: options, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) - .post( - '/login-verify', - validateBody({ - email: yd.string().email().required(), - response: yd.object().required(), - }), - async (ctx) => { - const { email, response } = ctx.request.body; - const user = await User.findOne({ email }); - - if (!user) { - ctx.throw(400, 'No user exists for that email.'); - } - - try { - await verifyAuthenticationResponse(user, response); - - ctx.body = { - data: { - token: await login(ctx, user), - }, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) - .post('/register-generate', validateBody(signupValidation), async (ctx) => { - const { body } = ctx.request; - const { email } = body; - - // Note: Register here technically refers to registering an authenticator. - // For now we are effectively only allowing a single authenticator per - // user although this is stored in an array to allow this as an option - // in the future. - if (await User.exists({ email })) { - ctx.throw(400, 'A user with that email already exists'); - } - - try { - const user = new User(ctx.request.body); - const options = await generateRegistrationOptions(user); - await user.save(); - ctx.body = { - data: options, - }; - } catch (error) { - ctx.throw(400, error); - } - }) - .post( - '/register-verify', - validateBody({ - email: yd.string().email().required(), - response: yd.object().required(), - }), - async (ctx) => { - const { email, response } = ctx.request.body; - - const user = await User.findOne({ email }); - - if (!user) { - ctx.throw(400, 'No user exists for that email.'); - } - - try { - await verifyRegistrationResponse(user, response); - const token = await register(ctx, user); - - ctx.body = { - data: { - token, - }, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) - .use(authenticate()) - .post('/enable-generate', async (ctx) => { - const { authUser } = ctx.state; - - try { - const options = await generateRegistrationOptions(authUser); - await authUser.save(); - ctx.body = { - data: options, - }; - } catch (error) { - ctx.throw(400, error); - } - }) - .post( - '/enable-verify', - validateBody({ - response: yd.object().required(), - }), - async (ctx) => { - const { authUser } = ctx.state; - const { response } = ctx.request.body; - - try { - await verifyRegistrationResponse(authUser, response); - await authUser.save(); - - ctx.body = { - data: authUser, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) - .post('/disable', async (ctx) => { - const { authUser } = ctx.state; - removePasskey(authUser); - await authUser.save(); - - ctx.body = { - data: authUser, - }; - }); - -module.exports = router; diff --git a/services/api/src/routes/auth/passkey/utils.js b/services/api/src/routes/auth/passkey/utils.js deleted file mode 100644 index 853844afa..000000000 --- a/services/api/src/routes/auth/passkey/utils.js +++ /dev/null @@ -1,128 +0,0 @@ -const SimpleWebAuthn = require('@simplewebauthn/server'); -const config = require('@bedrockio/config'); - -const { clearAuthenticators, getRequiredAuthenticator } = require('../../../utils/auth/authenticators'); - -const APP_NAME = config.get('APP_NAME'); -const APP_URL = config.get('APP_URL'); - -// Human-readable app name. -const rpName = APP_NAME; - -// A unique identifier for your website. -// For SSO this should be the root domain. -const rpID = getRootDomain(APP_URL); - -// The URL at which registrations and authentications should occur -const origin = config.get('APP_URL'); - -async function generateRegistrationOptions(user) { - // Only allow a single passkey at a time. - removePasskey(user); - - const options = await SimpleWebAuthn.generateRegistrationOptions({ - rpID, - rpName, - userName: user.name, - // Don't prompt users for additional information about the authenticator - // (Recommended for smoother UX) - attestationType: 'none', - }); - - user.authenticators.push({ - type: 'passkey', - info: options, - }); - - return options; -} - -async function verifyRegistrationResponse(user, response) { - const passkey = getRequiredAuthenticator(user, 'passkey'); - - const { registrationInfo } = await SimpleWebAuthn.verifyRegistrationResponse({ - response, - expectedChallenge: passkey.info.challenge, - expectedOrigin: origin, - expectedRPID: rpID, - }); - - const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; - - passkey.info = { - ...passkey.info, - // A unique identifier for the credential - id: credential.id, - // The public key bytes, used for subsequent authentication signature verification - publicKey: credential.publicKey, - // The number of times the authenticator has been used on this site so far - counter: credential.counter, - // How the browser can talk with this credential's authenticator - transports: credential.transports, - // Whether the passkey is single-device or multi-device - deviceType: credentialDeviceType, - // Whether the passkey has been backed up in some way - backedUp: credentialBackedUp, - }; -} - -async function generateAuthenticationOptions(user) { - const passkey = getRequiredAuthenticator(user, 'passkey'); - const options = await SimpleWebAuthn.generateAuthenticationOptions({ - rpID, - allowCredentials: [ - { - id: passkey.info.id, - transports: passkey.info.transports, - }, - ], - userVerification: 'preferred', - }); - passkey.info = { - ...passkey.info, - challenge: options.challenge, - }; - - return options; -} - -async function verifyAuthenticationResponse(user, response) { - const passkey = getRequiredAuthenticator(user, 'passkey'); - const { verified, authenticationInfo } = await SimpleWebAuthn.verifyAuthenticationResponse({ - response, - expectedChallenge: passkey.info.challenge, - expectedOrigin: origin, - expectedRPID: rpID, - credential: { - id: passkey.info.id, - counter: passkey.info.counter, - transports: passkey.info.transports, - publicKey: new Uint8Array(passkey.info.publicKey.buffer), - }, - }); - if (!verified) { - throw new Error('Could not verify authentication response.'); - } - - passkey.info = { - ...passkey.info, - counter: authenticationInfo.newCounter, - }; -} - -function removePasskey(user) { - clearAuthenticators(user, 'passkey'); -} - -function getRootDomain() { - const { hostname } = new URL(APP_URL); - return hostname.split('.').slice(-2).join('.'); -} - -module.exports = { - generateRegistrationOptions, - verifyRegistrationResponse, - generateAuthenticationOptions, - verifyAuthenticationResponse, - removePasskey, -}; diff --git a/services/api/src/routes/auth/password/index.js b/services/api/src/routes/auth/password.js similarity index 59% rename from services/api/src/routes/auth/password/index.js rename to services/api/src/routes/auth/password.js index 0b0e7ef42..5e31a140f 100644 --- a/services/api/src/routes/auth/password/index.js +++ b/services/api/src/routes/auth/password.js @@ -1,39 +1,19 @@ const Router = require('@koa/router'); const yd = require('@bedrockio/yada'); -const { validateBody } = require('../../../utils/middleware/validate'); -const { authenticate } = require('../../../utils/middleware/authenticate'); +const { validateBody } = require('../../utils/middleware/validate'); +const { authenticate } = require('../../utils/middleware/authenticate'); -const { createAuthToken, createTemporaryAuthToken } = require('../../../utils/auth/tokens'); -const { register, login, signupValidation, verifyLoginAttempts } = require('../../../utils/auth'); -const { verifyPassword } = require('../../../utils/auth/password'); -const { createOtp } = require('../../../utils/auth/otp'); -const { mailer, sms } = require('../../../utils/messaging'); -const { User, AuditEntry } = require('../../../models'); +const { createAuthToken, createTemporaryAuthToken } = require('../../utils/auth/tokens'); +const { login, verifyLoginAttempts } = require('../../utils/auth'); +const { verifyPassword } = require('../../utils/auth/password'); +const { sendOtp } = require('../../utils/auth/otp'); +const { mailer } = require('../../utils/messaging'); +const { User, AuditEntry } = require('../../models'); const router = new Router(); router - .post( - '/register', - validateBody( - signupValidation.append({ - password: yd.string().password().required(), - }) - ), - async (ctx) => { - try { - const user = new User(ctx.request.body); - ctx.body = { - data: { - token: await register(ctx, user), - }, - }; - } catch (error) { - ctx.throw(400, error); - } - } - ) .post( '/login', validateBody({ @@ -55,15 +35,13 @@ router try { await verifyLoginAttempts(user, ctx); } catch (error) { - await user.save(); ctx.throw(401, error); } try { await verifyPassword(user, password); } catch (error) { - await user.save(); - await AuditEntry.append('Password incorrect', { + await AuditEntry.append('Password Incorrect', { ctx, actor: user, category: 'security', @@ -71,38 +49,23 @@ router ctx.throw(401, error); } - let next; let token; + let challenge; const { mfaMethod } = user; - if (mfaMethod === 'sms') { - const code = await createOtp(user); - await sms.sendMessage({ - user, - code, - template: 'otp', - }); - next = { - type: 'otp', - phone: user.phone, - }; - } else if (mfaMethod === 'email') { - const code = await createOtp(user); - await mailer.sendMail({ - user, - code, - template: 'otp', + if (mfaMethod === 'email' || mfaMethod === 'sms') { + challenge = await sendOtp(user, { + type: 'code', + phase: 'login', + transport: mfaMethod, }); - next = { - type: 'otp', - email: user.email, - }; } else if (mfaMethod === 'totp') { - next = { - type: 'totp', + challenge = { + type: 'code', + transport: 'authenticator', email: user.email, }; - } else { + } else if (mfaMethod === 'none') { try { token = await login(ctx, user); } catch (error) { @@ -112,8 +75,8 @@ router ctx.body = { data: { - next, token, + challenge, }, }; } diff --git a/services/api/src/routes/auth/totp/index.js b/services/api/src/routes/auth/totp.js similarity index 73% rename from services/api/src/routes/auth/totp/index.js rename to services/api/src/routes/auth/totp.js index 003f3a8d6..32b32bf1a 100644 --- a/services/api/src/routes/auth/totp/index.js +++ b/services/api/src/routes/auth/totp.js @@ -1,14 +1,15 @@ const Router = require('@koa/router'); const yd = require('@bedrockio/yada'); -const { validateBody } = require('../../../utils/middleware/validate'); -const { authenticate } = require('../../../utils/middleware/authenticate'); +const { validateBody } = require('../../utils/middleware/validate'); +const { authenticate } = require('../../utils/middleware/authenticate'); -const { verifyRecentPassword } = require('../../../utils/auth/password'); -const { login, verifyLoginAttempts } = require('../../../utils/auth'); -const { expandRoles } = require('../../../utils/permissions'); -const { User, AuditEntry } = require('../../../models'); +const { expandRoles } = require('../../utils/permissions'); +const { login, verifyLoginAttempts } = require('../../utils/auth'); +const { verifyRecentPassword } = require('../../utils/auth/password'); +const { verifyCode, verifyTotp, generateTotp, enableTotp, revokeTotp } = require('../../utils/auth/totp'); -const { verifyCode, verifyTotp, generateTotp, enableTotp, revokeTotp } = require('./utils'); +const { AuditEntry } = require('../../models'); +const { findUser } = require('./utils'); const router = new Router(); @@ -16,15 +17,14 @@ router .post( '/login', validateBody({ - email: yd.string().email().required(), + phone: yd.string().phone(), + email: yd.string().email(), code: yd.string().length(6).required(), }), async (ctx) => { - const { email, code } = ctx.request.body; + const { code } = ctx.request.body; - const user = await User.findOne({ - email, - }); + const user = await findUser(ctx); if (!user) { ctx.throw(400, 'User not found.'); @@ -94,10 +94,9 @@ router } ) .post('/disable', async (ctx) => { - let { authUser } = ctx.state; + const { authUser } = ctx.state; try { - revokeTotp(authUser); - await authUser.save(); + await revokeTotp(authUser); ctx.body = { data: expandRoles(authUser, ctx), }; diff --git a/services/api/src/routes/auth/utils.js b/services/api/src/routes/auth/utils.js new file mode 100644 index 000000000..4edf6c4a1 --- /dev/null +++ b/services/api/src/routes/auth/utils.js @@ -0,0 +1,26 @@ +const { User } = require('../../models'); + +async function findUser(ctx) { + const { phone, email } = ctx.request.body; + + let query; + if (phone) { + query = { phone }; + } else if (email) { + query = { email }; + } else { + if (phone === '') { + ctx.throw(400, 'Phone is required.'); + } else if (email === '') { + ctx.throw(400, 'Email is required.'); + } else { + ctx.throw(400, 'Phone or email is required.'); + } + } + + return await User.findOne(query); +} + +module.exports = { + findUser, +}; diff --git a/services/api/src/routes/index.js b/services/api/src/routes/index.js index f07036fe3..f3f657070 100644 --- a/services/api/src/routes/index.js +++ b/services/api/src/routes/index.js @@ -7,6 +7,7 @@ const shops = require('./shops'); const uploads = require('./uploads'); const invites = require('./invites'); const status = require('./status'); +const signup = require('./signup'); const categories = require('./categories'); const auditEntries = require('./audit-entries'); const organizations = require('./organizations'); @@ -24,6 +25,7 @@ router.use('/shops', shops.routes()); router.use('/uploads', uploads.routes()); router.use('/invites', invites.routes()); router.use('/status', status.routes()); +router.use('/signup', signup.routes()); router.use('/categories', categories.routes()); router.use('/audit-entries', auditEntries.routes()); router.use('/organizations', organizations.routes()); diff --git a/services/api/src/routes/invites.js b/services/api/src/routes/invites.js index 04acc4234..ced9f9940 100644 --- a/services/api/src/routes/invites.js +++ b/services/api/src/routes/invites.js @@ -6,11 +6,10 @@ const { validateToken } = require('../utils/middleware/tokens'); const { authenticate } = require('../utils/middleware/authenticate'); const { requirePermissions } = require('../utils/middleware/permissions'); -const { register } = require('../utils/auth'); const { createAuthToken } = require('../utils/auth/tokens'); -const { Invite, User } = require('../models'); +const { Invite, User, AuditEntry } = require('../models'); -const mailer = require('../utils/messaging/mailer'); +const { sendMessage, mailer } = require('../utils/messaging'); const { createInviteToken } = require('../utils/auth/tokens'); const router = new Router(); @@ -74,7 +73,18 @@ router }), }); - const token = await register(ctx, user); + const token = createAuthToken(ctx, user); + await user.save(); + + await AuditEntry.append('Registered by Invite', { + ctx, + actor: user, + }); + + await sendMessage({ + user, + template: 'welcome', + }); ctx.body = { data: { diff --git a/services/api/src/routes/signup.js b/services/api/src/routes/signup.js new file mode 100644 index 000000000..976ffb4ce --- /dev/null +++ b/services/api/src/routes/signup.js @@ -0,0 +1,94 @@ +const Router = require('@koa/router'); +const yd = require('@bedrockio/yada'); + +const { sendOtp } = require('../utils/auth/otp'); +const { sendMessage } = require('../utils/messaging'); +const { createAuthToken } = require('../utils/auth/tokens'); +const { validateBody } = require('../utils/middleware/validate'); + +const { User, AuditEntry } = require('../models'); + +const router = new Router(); + +router.post( + '/', + validateBody({ + type: yd.string().allow('link', 'code', 'password').default('password'), + transport: yd.string().allow('email', 'sms').default('email'), + firstName: yd.string().required(), + lastName: yd.string().required(), + password: yd + .string() + .password() + .missing(({ root }) => { + if (root.type === 'password') { + throw new Error('Password is required.'); + } + }), + email: yd + .string() + .email() + .custom(async (val) => { + if (await User.exists({ email: val })) { + throw new Error('A user with that email already exists.'); + } + }) + .missing(({ root }) => { + if (root.transport === 'email') { + throw new Error('Email is required.'); + } + }), + phone: yd + .string() + .phone() + .custom(async (val) => { + if (await User.exists({ phone: val })) { + throw new Error('A user with that phone number already exists.'); + } + }) + .missing(({ root }) => { + if (root.transport === 'sms') { + throw new Error('Phone is required.'); + } + }), + }), + async (ctx) => { + const { type, transport, password, ...rest } = ctx.request.body; + const user = await User.create({ + ...rest, + password, + }); + + await AuditEntry.append('Signed Up', { + ctx, + actor: user, + }); + + let token; + let challenge; + + if (password) { + await sendMessage({ + user, + template: 'welcome', + }); + token = createAuthToken(ctx, user); + await user.save(); + } else { + challenge = await sendOtp(user, { + type, + transport, + phase: 'signup', + }); + } + + ctx.body = { + data: { + token, + challenge, + }, + }; + } +); + +module.exports = router; diff --git a/services/api/src/sms/otp-login-code.txt b/services/api/src/sms/otp-login-code.txt new file mode 100644 index 000000000..120e96eb5 --- /dev/null +++ b/services/api/src/sms/otp-login-code.txt @@ -0,0 +1 @@ +Your {{APP_NAME}} verification code is: {{code}} \ No newline at end of file diff --git a/services/api/src/sms/otp-login-link.txt b/services/api/src/sms/otp-login-link.txt new file mode 100644 index 000000000..19a560e94 --- /dev/null +++ b/services/api/src/sms/otp-login-link.txt @@ -0,0 +1 @@ +Sign in to your account: {{APP_URL}}/confirm-code?code={{{code}}} \ No newline at end of file diff --git a/services/api/src/sms/otp-signup-code.txt b/services/api/src/sms/otp-signup-code.txt new file mode 100644 index 000000000..120e96eb5 --- /dev/null +++ b/services/api/src/sms/otp-signup-code.txt @@ -0,0 +1 @@ +Your {{APP_NAME}} verification code is: {{code}} \ No newline at end of file diff --git a/services/api/src/sms/otp-signup-link.txt b/services/api/src/sms/otp-signup-link.txt new file mode 100644 index 000000000..22c4af1b6 --- /dev/null +++ b/services/api/src/sms/otp-signup-link.txt @@ -0,0 +1 @@ +Complete your account registration: {{APP_URL}}/confirm-code?code={{{code}}} diff --git a/services/api/src/sms/otp.txt b/services/api/src/sms/otp.txt deleted file mode 100644 index d801aa528..000000000 --- a/services/api/src/sms/otp.txt +++ /dev/null @@ -1 +0,0 @@ -Your login code is: {{code}} diff --git a/services/api/src/routes/auth/apple/utils.js b/services/api/src/utils/auth/apple.js similarity index 68% rename from services/api/src/routes/auth/apple/utils.js rename to services/api/src/utils/auth/apple.js index 19cd9c42e..53e980cfe 100644 --- a/services/api/src/routes/auth/apple/utils.js +++ b/services/api/src/utils/auth/apple.js @@ -1,7 +1,7 @@ const verifyAppleToken = require('verify-apple-id-token').default; const config = require('@bedrockio/config'); -const { clearAuthenticators } = require('../../../utils/auth/authenticators'); +const { clearAuthenticators, upsertAuthenticator } = require('./authenticators'); const APPLE_SERVICE_ID = config.get('APPLE_SERVICE_ID'); @@ -13,14 +13,15 @@ async function verifyToken(token) { if (!payload.email_verified) { throw new Error('Email not verified.'); } - return payload; + return { + email: payload.email, + emailVerified: payload.email_verified, + }; } -function addAppleAuthenticator(user) { - clearAuthenticators(user, 'apple'); - user.authenticators.push({ +function upsertAppleAuthenticator(user) { + upsertAuthenticator(user, { type: 'apple', - verifiedAt: new Date(), }); } @@ -30,6 +31,6 @@ function removeAppleAuthenticator(user) { module.exports = { verifyToken, - addAppleAuthenticator, + upsertAppleAuthenticator, removeAppleAuthenticator, }; diff --git a/services/api/src/utils/auth/authenticators.js b/services/api/src/utils/auth/authenticators.js index 48ca3083e..9aa3b737a 100644 --- a/services/api/src/utils/auth/authenticators.js +++ b/services/api/src/utils/auth/authenticators.js @@ -4,11 +4,25 @@ function getAuthenticator(user, type) { }); } +function getAuthenticators(user, type) { + return user.authenticators.filter((authenticator) => { + return authenticator.type === type; + }); +} + function hasAuthenticator(user, type) { return !!getAuthenticator(user, type); } -function getRequiredAuthenticator(user, type) { +function addAuthenticator(user, attributes) { + user.authenticators.push({ + createdAt: new Date(), + lastUsedAt: new Date(), + ...attributes, + }); +} + +function assertAuthenticator(user, type) { const authenticator = getAuthenticator(user, type); if (!authenticator) { throw Error(`No ${type} set.`); @@ -16,7 +30,26 @@ function getRequiredAuthenticator(user, type) { return authenticator; } -// Clear existing authenticators of a given type. +// Create + +function upsertAuthenticator(user, attributes) { + const { type } = attributes; + let authenticator = getAuthenticator(user, type); + if (authenticator) { + authenticator.lastUsedAt = new Date(); + } else { + addAuthenticator(user, attributes); + } +} + +// Delete + +function removeAuthenticator(user, id) { + user.authenticators = user.authenticators.filter((authenticator) => { + return authenticator.id !== id; + }); +} + function clearAuthenticators(user, type) { user.authenticators = user.authenticators.filter((authenticator) => { return authenticator.type !== type; @@ -24,8 +57,12 @@ function clearAuthenticators(user, type) { } module.exports = { + addAuthenticator, getAuthenticator, hasAuthenticator, + getAuthenticators, clearAuthenticators, - getRequiredAuthenticator, + removeAuthenticator, + assertAuthenticator, + upsertAuthenticator, }; diff --git a/services/api/src/routes/auth/google/utils.js b/services/api/src/utils/auth/google.js similarity index 54% rename from services/api/src/routes/auth/google/utils.js rename to services/api/src/utils/auth/google.js index 2f7b46f69..3a0a36edb 100644 --- a/services/api/src/routes/auth/google/utils.js +++ b/services/api/src/utils/auth/google.js @@ -1,14 +1,22 @@ const { OAuth2Client } = require('google-auth-library'); +const { clearAuthenticators, upsertAuthenticator } = require('./authenticators'); const config = require('@bedrockio/config'); -const { clearAuthenticators } = require('../../../utils/auth/authenticators'); - -const client = new OAuth2Client(); const GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID'); +const GOOGLE_CLIENT_SECRET = config.get('GOOGLE_CLIENT_SECRET'); +const APP_URL = config.get('APP_URL'); + +const client = new OAuth2Client({ + clientId: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + redirectUri: APP_URL, +}); -async function verifyToken(token) { +async function verifyToken(code) { + const { tokens } = await client.getToken(code); + const { id_token: idToken } = tokens; const ticket = await client.verifyIdToken({ - idToken: token, + idToken, audience: GOOGLE_CLIENT_ID, }); const payload = ticket.getPayload(); @@ -22,11 +30,9 @@ async function verifyToken(token) { }; } -function addGoogleAuthenticator(user) { - clearAuthenticators(user, 'google'); - user.authenticators.push({ +function upsertGoogleAuthenticator(user) { + upsertAuthenticator(user, { type: 'google', - verifiedAt: new Date(), }); } @@ -36,6 +42,6 @@ function removeGoogleAuthenticator(user) { module.exports = { verifyToken, - addGoogleAuthenticator, + upsertGoogleAuthenticator, removeGoogleAuthenticator, }; diff --git a/services/api/src/utils/auth/index.js b/services/api/src/utils/auth/index.js index 59ce31022..778a39706 100644 --- a/services/api/src/utils/auth/index.js +++ b/services/api/src/utils/auth/index.js @@ -1,5 +1,4 @@ module.exports = { ...require('./login'), - ...require('./register'), - ...require('./validation'), + ...require('./tokens'), }; diff --git a/services/api/src/utils/auth/login.js b/services/api/src/utils/auth/login.js index 7a81d64a3..b535c20ce 100644 --- a/services/api/src/utils/auth/login.js +++ b/services/api/src/utils/auth/login.js @@ -17,15 +17,18 @@ const LOGIN_TIMEOUT_RULES = [ }, ]; -async function login(ctx, user) { +async function login(ctx, user, options = {}) { + const { message = 'Logged In' } = options; + const token = createAuthToken(ctx, user); removeExpiredTokens(user); user.loginAttempts = 0; await user.save(); - await AuditEntry.append('Logged In', { + await AuditEntry.append(message, { ctx, actor: user, + category: 'auth', }); return token; diff --git a/services/api/src/utils/auth/otp.js b/services/api/src/utils/auth/otp.js index 095379791..8f4e5ddfb 100644 --- a/services/api/src/utils/auth/otp.js +++ b/services/api/src/utils/auth/otp.js @@ -1,6 +1,7 @@ const { customAlphabet } = require('nanoid'); +const { sendMessage } = require('../messaging'); -const { clearAuthenticators, getRequiredAuthenticator } = require('./authenticators'); +const { clearAuthenticators, addAuthenticator, assertAuthenticator } = require('./authenticators'); const generateCode = customAlphabet('1234567890', 6); @@ -10,12 +11,50 @@ const TESTER_CODE = '111111'; // 1 hour const EXPIRE = 60 * 60 * 1000; +async function sendOtp(user, body) { + if (!user) { + return createChallenge(body); + } + + const { type, phase, transport } = body; + + const template = `otp-${phase}-${type}`; + const code = await createOtp(user); + + if (user.isTester) { + return createChallenge({ + ...body, + code, + }); + } else { + await sendMessage({ + user, + code, + template, + transport, + }); + return createChallenge(body, user); + } +} + +function createChallenge(body, target = body) { + const { type, transport, code } = body; + const field = transport === 'sms' ? 'phone' : 'email'; + + return { + type, + code, + transport, + [field]: target[field], + }; +} + async function createOtp(user) { clearAuthenticators(user, 'otp'); const code = user.isTester ? TESTER_CODE : generateCode(); - user.authenticators.push({ + addAuthenticator(user, { type: 'otp', code, expiresAt: new Date(Date.now() + EXPIRE), @@ -27,7 +66,7 @@ async function createOtp(user) { } function verifyOtp(user, code) { - const authenticator = getRequiredAuthenticator(user, 'otp'); + const authenticator = assertAuthenticator(user, 'otp'); if (authenticator.code === code) { const dt = authenticator.expiresAt - new Date(); if (dt <= 0) { @@ -39,12 +78,8 @@ function verifyOtp(user, code) { } } -function getOtp(user) { - return getRequiredAuthenticator(user, 'otp').code; -} - module.exports = { - getOtp, + sendOtp, createOtp, verifyOtp, }; diff --git a/services/api/src/utils/auth/passkey.js b/services/api/src/utils/auth/passkey.js new file mode 100644 index 000000000..5bdee11dc --- /dev/null +++ b/services/api/src/utils/auth/passkey.js @@ -0,0 +1,196 @@ +const SimpleWebAuthn = require('@simplewebauthn/server'); +const config = require('@bedrockio/config'); + +const { getAuthenticators, removeAuthenticator, addAuthenticator } = require('./authenticators'); +const { createPasskeyToken, verifyToken } = require('./tokens'); +const { User } = require('../../models'); + +const APP_NAME = config.get('APP_NAME'); +const APP_URL = config.get('APP_URL'); + +// Human-readable app name. +const rpName = APP_NAME; + +// A unique identifier for your website. +// For SSO this should be the root domain. +const rpID = getRootDomain(APP_URL); + +// The URL at which registrations and authentications should occur +const origin = config.get('APP_URL'); + +async function generateRegistrationOptions(user) { + const passkeys = getAuthenticators(user, 'passkey'); + + const options = await SimpleWebAuthn.generateRegistrationOptions({ + rpID, + rpName, + // It seems that good practice when using passkeys is to + // have a unique identifier as the userName as they may + // have different logins under the same name. Amazon and + // others use the email where GitHub uses the username. + userName: user.email || user.username, + // Prevent users from re-registering existing authenticators + excludeCredentials: passkeys.map((passkey) => ({ + id: passkey.info.id, + transports: passkey.info.transports, + })), + // Don't prompt users for additional information about the authenticator + // (Recommended for smoother UX) + attestationType: 'none', + }); + + const token = createPasskeyToken({ + challenge: options.challenge, + }); + + return { + token, + options, + }; +} + +async function registerNewPasskey(user, options) { + const { token, response } = options; + + const payload = verifyToken(token); + + const { registrationInfo } = await SimpleWebAuthn.verifyRegistrationResponse({ + response, + expectedChallenge: payload.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + + const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; + + const name = getPasskeyName(user, options); + + addAuthenticator(user, { + type: 'passkey', + name, + info: { + // A unique identifier for the credential + id: credential.id, + // The public key bytes, used for subsequent authentication signature verification + publicKey: credential.publicKey, + // The number of times the authenticator has been used on this site so far + counter: credential.counter, + // How the browser can talk with this credential's authenticator + transports: credential.transports, + // Whether the passkey is single-device or multi-device + deviceType: credentialDeviceType, + // Whether the passkey has been backed up in some way + backedUp: credentialBackedUp, + }, + }); + + await user.save(); +} + +function getPasskeyName(user, options) { + try { + const { ctx } = options; + const name = JSON.parse(ctx.get('sec-ch-ua-platform')); + if (!name) { + throw new Error(); + } + return name; + } catch (error) { + const authenticators = getAuthenticators(user, 'passkey'); + const number = authenticators.length + 1; + return `Passkey ${number}`; + } +} + +async function generateAuthenticationOptions() { + const options = await SimpleWebAuthn.generateAuthenticationOptions({ + rpID, + // Note keeping allowCredentials empty here to allow the + // user to choose from any discoverable credentials they + // may have. Doing this: + // + // 1. Allows "seamless" authentication where they do not + // have to provide an email or username. + // 2. Still allows multiple accounts. + // + // https://simplewebauthn.dev/docs/advanced/passkeys#generateauthenticationoptions + // + allowCredentials: [], + userVerification: 'preferred', + }); + const token = createPasskeyToken({ + challenge: options.challenge, + }); + return { + token, + options, + }; +} + +async function authenticatePasskeyResponse(options) { + const { token, response } = options; + + const id = response?.id; + + if (!id) { + throw new Error('Invalid response.'); + } + + const payload = verifyToken(token); + + const user = await User.findOne({ + 'authenticators.type': 'passkey', + 'authenticators.info.id': id, + }); + + if (!user) { + throw new Error('No user found for passkey. You may need to remove it.'); + } + + const passkey = user.authenticators.find((authenticator) => { + return authenticator.info?.id === id; + }); + + const { verified, authenticationInfo } = await SimpleWebAuthn.verifyAuthenticationResponse({ + response, + expectedChallenge: payload.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.info.id, + counter: passkey.info.counter, + transports: passkey.info.transports, + publicKey: new Uint8Array(passkey.info.publicKey.buffer), + }, + }); + + if (!verified) { + throw new Error('Could not verify authentication response.'); + } + + passkey.info = { + ...passkey.info, + counter: authenticationInfo.newCounter, + }; + + passkey.lastUsedAt = new Date(); + + return user; +} + +function removePasskey(user, id) { + removeAuthenticator(user, id); +} + +function getRootDomain() { + const { hostname } = new URL(APP_URL); + return hostname.split('.').slice(-2).join('.'); +} + +module.exports = { + generateRegistrationOptions, + registerNewPasskey, + generateAuthenticationOptions, + authenticatePasskeyResponse, + removePasskey, +}; diff --git a/services/api/src/utils/auth/password.js b/services/api/src/utils/auth/password.js index e72daca8f..634ecda07 100644 --- a/services/api/src/utils/auth/password.js +++ b/services/api/src/utils/auth/password.js @@ -1,29 +1,32 @@ const bcrypt = require('bcrypt'); -const { getAuthenticator, clearAuthenticators, getRequiredAuthenticator } = require('./authenticators'); +const { getAuthenticator, clearAuthenticators, addAuthenticator, assertAuthenticator } = require('./authenticators'); // 5 minutes const MFA_THRESHOLD = 5 * 60 * 1000; async function verifyPassword(user, password) { - const authenticator = getRequiredAuthenticator(user, 'password'); + const authenticator = assertAuthenticator(user, 'password'); const match = await bcrypt.compare(password, authenticator.secret); if (!match) { throw Error('Incorrect password.'); } - authenticator.verifiedAt = new Date(); + authenticator.lastUsedAt = new Date(); + + await user.save(); } // To allow OTP login, passwords may be changed to optional. function verifyRecentPassword(user) { const authenticator = getAuthenticator(user, 'password'); + if (!authenticator) { return; } - const dt = new Date() - authenticator.verifiedAt; + const dt = new Date() - authenticator.lastUsedAt; if (dt > MFA_THRESHOLD) { throw new Error('Password not verified.'); } @@ -35,9 +38,8 @@ async function setPassword(user, password) { clearAuthenticators(user, 'password'); - user.authenticators.push({ + addAuthenticator(user, { type: 'password', - verifiedAt: new Date(), secret: hash, }); } diff --git a/services/api/src/utils/auth/register.js b/services/api/src/utils/auth/register.js deleted file mode 100644 index a29663697..000000000 --- a/services/api/src/utils/auth/register.js +++ /dev/null @@ -1,24 +0,0 @@ -const { AuditEntry } = require('../../models'); -const { createAuthToken } = require('./tokens'); -const { sendMessage } = require('../messaging'); - -async function register(ctx, user) { - const token = createAuthToken(ctx, user); - await user.save(); - - await AuditEntry.append('Registered', { - ctx, - actor: user, - }); - - await sendMessage({ - user, - template: 'welcome', - }); - - return token; -} - -module.exports = { - register, -}; diff --git a/services/api/src/utils/auth/tokens.js b/services/api/src/utils/auth/tokens.js index 5fc686035..dd38f1c68 100644 --- a/services/api/src/utils/auth/tokens.js +++ b/services/api/src/utils/auth/tokens.js @@ -1,14 +1,14 @@ +const ms = require('ms'); const jwt = require('jsonwebtoken'); const config = require('@bedrockio/config'); const { nanoid } = require('nanoid'); const JWT_SECRET = config.get('JWT_SECRET'); -// All expires are expressed in seconds (jwt spec) -const expiresIn = { - invite: 24 * 60 * 60, // 1 day - regular: 30 * 24 * 60 * 60, // 30 days - temporary: 60 * 60, // 1 hour +const DURATIONS = { + invite: '1d', + regular: '30d', + temporary: '1h', }; function createAuthToken(ctx, user, options = {}) { @@ -20,7 +20,8 @@ function createAuthToken(ctx, user, options = {}) { const country = ctx.get('cf-ipcountry')?.toUpperCase(); const userAgent = ctx.get('user-agent'); - const payload = getAuthTokenPayload(user, type); + const payload = getAuthTokenPayload(user); + const duration = DURATIONS[type]; const { jti } = payload; authUser.authTokens = [ @@ -31,12 +32,12 @@ function createAuthToken(ctx, user, options = {}) { jti, country, userAgent, - expiresAt: new Date(payload.exp * 1000), + expiresAt: new Date(Date.now() + ms(duration)), lastUsedAt: new Date(), }, ]; - return signAuthToken(payload, JWT_SECRET); + return signToken(payload, duration); } function createTemporaryAuthToken(ctx, user) { @@ -52,8 +53,27 @@ function createImpersonateAuthToken(ctx, user, authUser) { }); } -function verifyAuthToken(token) { - jwt.verify(token, JWT_SECRET); +function createInviteToken(invite) { + const duration = DURATIONS.invite; + return signToken( + { + kid: 'invite', + sub: invite.email, + jti: generateTokenId(), + }, + duration + ); +} + +function createPasskeyToken(payload) { + return signToken({ + kid: 'passkey', + ...payload, + }); +} + +function verifyToken(token) { + return jwt.verify(token, JWT_SECRET); } function removeAuthToken(user, jti) { @@ -65,28 +85,18 @@ function removeExpiredTokens(user) { user.authTokens = user.authTokens.filter((token) => token.expiresAt > now); } -function createInviteToken(invite) { - return signAuthToken({ - kid: 'invite', - sub: invite.email, - jti: generateTokenId(), - exp: Math.floor(Date.now() / 1000) + expiresIn.invite, +function signToken(payload, duration) { + duration ||= DURATIONS.temporary; + return jwt.sign(payload, JWT_SECRET, { + expiresIn: duration, }); } -function signAuthToken(payload) { - return jwt.sign(payload, JWT_SECRET); -} - -function getAuthTokenPayload(user, type) { - const { regular, temporary } = expiresIn; - const duration = type === 'regular' ? regular : temporary; +function getAuthTokenPayload(user) { return { kid: 'user', sub: user.id, jti: generateTokenId(), - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + duration, }; } @@ -96,13 +106,14 @@ function generateTokenId() { } module.exports = { + verifyToken, createAuthToken, - verifyAuthToken, removeAuthToken, getAuthTokenPayload, createInviteToken, + createPasskeyToken, removeExpiredTokens, createTemporaryAuthToken, createImpersonateAuthToken, - signAuthToken, + signToken, }; diff --git a/services/api/src/routes/auth/totp/utils.js b/services/api/src/utils/auth/totp.js similarity index 76% rename from services/api/src/routes/auth/totp/utils.js rename to services/api/src/utils/auth/totp.js index 541746a48..7445cafca 100644 --- a/services/api/src/routes/auth/totp/utils.js +++ b/services/api/src/utils/auth/totp.js @@ -1,7 +1,7 @@ const speakeasy = require('speakeasy'); const config = require('@bedrockio/config'); -const { clearAuthenticators, getRequiredAuthenticator } = require('../../../utils/auth/authenticators'); +const { clearAuthenticators, addAuthenticator, assertAuthenticator } = require('./authenticators'); const APP_NAME = config.get('APP_NAME'); @@ -28,25 +28,26 @@ function createSecret() { function enableTotp(user, secret) { clearAuthenticators(user, 'totp'); - user.authenticators.push({ + addAuthenticator(user, { type: 'totp', secret, - verifiedAt: new Date(), }); user.mfaMethod = 'totp'; } -function revokeTotp(user) { +async function revokeTotp(user) { clearAuthenticators(user, 'totp'); if (user.mfaMethod === 'totp') { - delete user.mfaMethod; + user.mfaMethod = 'none'; } + await user.save(); } function verifyTotp(user, code) { - const authenticator = getRequiredAuthenticator(user, 'totp'); + const authenticator = assertAuthenticator(user, 'totp'); verifyCode(authenticator.secret, code); + authenticator.lastUsedAt = new Date(); } function verifyCode(secret, code) { diff --git a/services/api/src/utils/auth/validation.js b/services/api/src/utils/auth/validation.js deleted file mode 100644 index 624559133..000000000 --- a/services/api/src/utils/auth/validation.js +++ /dev/null @@ -1,34 +0,0 @@ -const yd = require('@bedrockio/yada'); -const { User } = require('../../models'); - -const signupValidation = yd.object({ - email: yd - .string() - .email() - .required() - .custom(async (val) => { - if (await User.exists({ email: val })) { - throw new ProtectedError('A user with that email already exists.'); - } - }), - phone: yd - .string() - .phone() - .custom(async (val) => { - if (await User.exists({ phone: val })) { - throw new ProtectedError('A user with that phone number already exists.'); - } - }), - firstName: yd.string().required(), - lastName: yd.string().required(), -}); - -class ProtectedError extends Error { - constructor(msg) { - super(process.env.ENV_NAME === 'production' ? 'An error occurred.' : msg); - } -} - -module.exports = { - signupValidation, -}; diff --git a/services/api/src/utils/messaging/index.js b/services/api/src/utils/messaging/index.js index d44d9e752..21fab6244 100644 --- a/services/api/src/utils/messaging/index.js +++ b/services/api/src/utils/messaging/index.js @@ -2,11 +2,22 @@ const sms = require('./sms'); const mailer = require('./mailer'); async function sendMessage(options) { - const { user } = options; - if (user.email) { + const { user, transport = getTransport(user) } = options; + + if (transport === 'email') { await mailer.sendMail(options); - } else if (user.phone) { + } else if (transport === 'sms') { await sms.sendMessage(options); + } else { + throw new Error('No transport found to send message.'); + } +} + +function getTransport(user) { + if (user.email) { + return 'email'; + } else if (user.phone) { + return 'sms'; } } diff --git a/services/api/src/utils/middleware/__tests__/authenticate.js b/services/api/src/utils/middleware/__tests__/authenticate.js index d8148a09d..730e819ec 100644 --- a/services/api/src/utils/middleware/__tests__/authenticate.js +++ b/services/api/src/utils/middleware/__tests__/authenticate.js @@ -1,5 +1,5 @@ const { authenticate, authorizeUser } = require('../authenticate'); -const { getAuthTokenPayload, signAuthToken } = require('../../auth/tokens'); +const { getAuthTokenPayload, signToken } = require('../../auth/tokens'); const { context, createUser } = require('../../testing'); const { User } = require('../../../models'); @@ -18,7 +18,7 @@ describe('authenticate', () => { }); const payload = getAuthTokenPayload(user); - const token = signAuthToken(payload); + const token = signToken(payload); const ctx = context({ headers: { authorization: `Bearer ${token}` } }); await authenticate()(ctx, () => { diff --git a/services/api/src/utils/middleware/authenticate.js b/services/api/src/utils/middleware/authenticate.js index d039e5c56..e84960616 100644 --- a/services/api/src/utils/middleware/authenticate.js +++ b/services/api/src/utils/middleware/authenticate.js @@ -55,7 +55,7 @@ function authorizeUser() { async function updateAuthToken(ctx, user, token) { const ip = ctx.get('x-forwarded-for') || ctx.ip; - // update update the user if the token hasn't been updated in the last 30 seconds + // update the user if the token hasn't been updated in the last 30 seconds // or the ip address has changed if (token.lastUsedAt < Date.now() - 1000 * 30 || token.ip !== ip) { token.ip = ip; diff --git a/services/api/src/utils/middleware/tokens.js b/services/api/src/utils/middleware/tokens.js index e33ec9e67..9273e803d 100644 --- a/services/api/src/utils/middleware/tokens.js +++ b/services/api/src/utils/middleware/tokens.js @@ -1,6 +1,6 @@ const jwt = require('jsonwebtoken'); -const { verifyAuthToken } = require('../auth/tokens'); +const { verifyToken } = require('../auth/tokens'); class TokenError extends Error { type = 'token'; @@ -41,7 +41,7 @@ function validateToken(options = {}) { // confirming signature try { - verifyAuthToken(token); + verifyToken(token); ctx.state.jwt = payload; return next(); } catch (e) { diff --git a/services/api/src/utils/testing/request.js b/services/api/src/utils/testing/request.js index b3c321afc..d9360dc73 100644 --- a/services/api/src/utils/testing/request.js +++ b/services/api/src/utils/testing/request.js @@ -2,7 +2,7 @@ const request = require('supertest'); //eslint-disable-line const rootApp = require('../../app'); const qs = require('querystring'); const { Blob } = require('node:buffer'); -const { getAuthTokenPayload, signAuthToken } = require('../auth/tokens'); +const { getAuthTokenPayload, signToken } = require('../auth/tokens'); module.exports = async function handleRequest(httpMethod, url, bodyOrQuery = {}, options = {}) { const headers = options.headers || {}; @@ -10,7 +10,7 @@ module.exports = async function handleRequest(httpMethod, url, bodyOrQuery = {}, const { user } = options; const payload = getAuthTokenPayload(user); - const token = signAuthToken(payload); + const token = signToken(payload); headers.Authorization = `Bearer ${token}`; } else if (options.token) { diff --git a/services/api/src/utils/testing/setup/matchers.js b/services/api/src/utils/testing/setup/matchers.js new file mode 100644 index 000000000..c4868395f --- /dev/null +++ b/services/api/src/utils/testing/setup/matchers.js @@ -0,0 +1,26 @@ +function toHaveStatus(response, status) { + const pass = response.status === status; + const { printExpected, printReceived } = this.utils; + const expected = printExpected(status); + const received = printReceived(response.status); + + if (pass) { + return { + pass: true, + message: () => `expected a status of ${expected}`, + }; + } else { + return { + pass: false, + message: () => { + return ` +Status: ${received} + +${JSON.stringify(response.body, null, 2)} + `.trim(); + }, + }; + } +} + +expect.extend({ toHaveStatus }); diff --git a/services/api/yarn.lock b/services/api/yarn.lock index 04fb8314f..65a1eeb78 100644 --- a/services/api/yarn.lock +++ b/services/api/yarn.lock @@ -298,17 +298,16 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@bedrockio/config@^2.2.2", "@bedrockio/config@^2.2.3": +"@bedrockio/config@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@bedrockio/config/-/config-2.2.3.tgz#bac37d12e36a99ec16668c8b5d1e05dec9658fa9" integrity sha512-jfOcZIs63S0GaWQjh5vVIISr4b2vA0CWgm630N0FDB6wlW+O0Fsjox92Agx5nggVpSnNYWqzjuKaUb5RY3/ECw== -"@bedrockio/fixtures@^1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@bedrockio/fixtures/-/fixtures-1.2.5.tgz#43aed9428d49bcec25a60a0fb39cb70643db7025" - integrity sha512-4gt5dZQuDT8Eu2IJK92XbhpCpL/9Vyc1esypjuB5O8Mt/SEKwL8R4Hjj50WcJ8qfAohLJIU7KIQOFUDR/9YVkg== +"@bedrockio/fixtures@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@bedrockio/fixtures/-/fixtures-1.3.2.tgz#7ce7d2937cadb284aed7bba61728b2ff877a2b76" + integrity sha512-KqUwOTQWTRuOPge5QUWwo23toEwrzvsObACg3fI+B0D3jspHt/PzJqzj+VmIkAr9Qgdh5glXCu3tdHkF13nruQ== dependencies: - "@bedrockio/config" "^2.2.2" "@bedrockio/logger" "^1.0.3" glob "^8.1.0" jszip "^3.10.1" @@ -354,10 +353,10 @@ kleur "^4.1.5" stdout-stream "^2.0.0" -"@bedrockio/model@^0.8.2": - version "0.8.2" - resolved "https://registry.yarnpkg.com/@bedrockio/model/-/model-0.8.2.tgz#0faffaadfd8e0588ff49b082b5480a7bd14a34cb" - integrity sha512-CNg6pddk1knK0zWC8Kk7/RDsuT+bseh+KNUvpExOvYSTdvxojYHPXhjxt5pBiD6g8zr7/Q39VYPsxOJ9y/mBaQ== +"@bedrockio/model@^0.8.4": + version "0.8.4" + resolved "https://registry.yarnpkg.com/@bedrockio/model/-/model-0.8.4.tgz#abf8036db5343187f3017cf4bd05c85c97bd2c19" + integrity sha512-3X2JNN8YETN6/W0In4ee4Wqdtrkuo8QAgwO0AtebIECN3ZY/24vq+PAr8J5g3v5STTSs0DMzUVBJaLInt8Nb8g== dependencies: "@bedrockio/logger" "^1.1.1" lodash "^4.17.21" @@ -367,10 +366,10 @@ resolved "https://registry.yarnpkg.com/@bedrockio/prettier-config/-/prettier-config-1.0.2.tgz#89878381d3059b810f97514ee6b69f946a6d1654" integrity sha512-Ebcx/D9FcV6WEQH2gXA4SkN62fus/aVMW46MYFm1/y+S0GVYj+OCuBbRMM4F8YAq9ntJ+B/z9fLiOheNpEpRzA== -"@bedrockio/yada@^1.2.3": - version "1.2.3" - resolved "https://registry.yarnpkg.com/@bedrockio/yada/-/yada-1.2.3.tgz#6b6d650b2a348f597e7d3ccc61f736371e7725b5" - integrity sha512-yTGICYsurRpyiz2GznXOcUcKIfrqU0Hyf1ADXLhO3D2oeuo3V7pS72TgzoVr5KOzTlpGwTs6s5nWkkdUiRGW3w== +"@bedrockio/yada@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@bedrockio/yada/-/yada-1.2.8.tgz#a0cb33aeaeade93aab29c84e0382514171df0f7f" + integrity sha512-HPiN8zl+zV9r3MqLw9QRXya2uwNk4aM//h5r/RjjvEphrdjR06691wHrmttM/Z3g6O7jAjA2JLJIk695W8WPxw== dependencies: validator "^13.9.0" diff --git a/services/web/.env b/services/web/.env index e5b40731a..a5d78bd1f 100644 --- a/services/web/.env +++ b/services/web/.env @@ -28,14 +28,25 @@ SENTRY_DSN= # Google Tag Manager Analytics GTM_CONTAINER_ID= +# How to authenticate (password|link|code) +AUTH_TYPE=password +# Link/code send by (email|sms) +AUTH_TRANSPORT=email +# Allow passkey? +AUTH_PASSKEY= + # Google Maps and Address Lookup # https://console.cloud.google.com/apis/library/maps-backend.googleapis.com # https://console.cloud.google.com/apis/library/places-backend.googleapis.com GOOGLE_API_KEY= # Sign in with Google +# https://developers.google.com/identity/sign-in/web/sign-in GOOGLE_CLIENT_ID= # Sign in with Apple +# https://developer.apple.com/help/account/configure-app-capabilities/about-sign-in-with-apple/ APPLE_SERVICE_ID= -APPLE_RETURN_URL= \ No newline at end of file +# Note this must match the service configuration +# exactly. Be careful of trailing slash. +APPLE_RETURN_URL= diff --git a/services/web/package.json b/services/web/package.json index 57100cce7..38a30789e 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -69,7 +69,9 @@ "react-router": "^5.3.4", "react-router-dom": "^5.3.4", "react-syntax-highlighter": "^15.5.0", + "rehype-autolink-headings": "^7.1.0", "rehype-raw": "^6.1.1", + "rehype-slug": "^6.0.0", "remark-gfm": "^3.0.1", "semantic-ui-react": "^2.1.3", "style-loader": "^3.3.1", @@ -98,8 +100,8 @@ "webpack-bundle-analyzer": "^4.5.0" }, "volta": { - "node": "22.13.0", - "yarn": "1.22.19" + "node": "22.13.1", + "yarn": "1.22.22" }, "prettier": "@bedrockio/prettier-config" } diff --git a/services/web/src/auth/App.js b/services/web/src/AuthApp.js similarity index 85% rename from services/web/src/auth/App.js rename to services/web/src/AuthApp.js index 9b49424f6..b6aff8ccd 100644 --- a/services/web/src/auth/App.js +++ b/services/web/src/AuthApp.js @@ -1,18 +1,17 @@ import { hot } from 'react-hot-loader/root'; - import { Switch, Route } from 'react-router-dom'; import BasicLayout from 'layouts/Basic'; import Lockout from 'screens/Lockout'; -import Login from 'screens/Auth/Login/Password'; -import LoginCode from 'screens/Auth/Login/Code'; -import Signup from 'screens/Auth/Signup'; +import Login from 'screens/Auth/Login'; import Logout from 'screens/Auth/Logout'; +import Signup from 'screens/Auth/Signup'; import ForgotPassword from 'screens/Auth/ForgotPassword'; import ResetPassword from 'screens/Auth/ResetPassword'; import AcceptInvite from 'screens/Auth/AcceptInvite'; +import ConfirmCode from 'screens/Auth/ConfirmCode'; function App() { return ( @@ -20,8 +19,8 @@ function App() { - + diff --git a/services/web/src/OnboardApp.js b/services/web/src/OnboardApp.js new file mode 100644 index 000000000..10e19695f --- /dev/null +++ b/services/web/src/OnboardApp.js @@ -0,0 +1,20 @@ +import { hot } from 'react-hot-loader/root'; +import { Switch, Redirect } from 'react-router-dom'; + +import { Protected } from 'helpers/routes'; +import BasicLayout from 'layouts/Basic'; + +import Onboard from 'screens/Onboard'; + +function App() { + return ( + + + + + + + ); +} + +export default hot(App); diff --git a/services/web/src/assets/apple-logo-white.svg b/services/web/src/assets/apple-logo-white.svg new file mode 100644 index 000000000..e480bab73 --- /dev/null +++ b/services/web/src/assets/apple-logo-white.svg @@ -0,0 +1,7 @@ + + + Artboard + + + + \ No newline at end of file diff --git a/services/web/src/assets/google-logo.svg b/services/web/src/assets/google-logo.svg new file mode 100644 index 000000000..213706d8d --- /dev/null +++ b/services/web/src/assets/google-logo.svg @@ -0,0 +1,12 @@ + + + Artboard + + + + + + + + + \ No newline at end of file diff --git a/services/web/src/components/Auth/Apple/DisableButton.js b/services/web/src/components/Auth/Apple/DisableButton.js index b81ce8cef..2b45c4d82 100644 --- a/services/web/src/components/Auth/Apple/DisableButton.js +++ b/services/web/src/components/Auth/Apple/DisableButton.js @@ -5,7 +5,7 @@ import { Button } from 'semantic'; import { withSession } from 'stores/session'; -import { disable } from './utils'; +import { disable } from 'utils/auth/apple'; @withSession export default class DisableButton extends React.Component { diff --git a/services/web/src/components/Auth/Apple/SignInButton.js b/services/web/src/components/Auth/Apple/SignInButton.js index ed228beec..b9514c732 100644 --- a/services/web/src/components/Auth/Apple/SignInButton.js +++ b/services/web/src/components/Auth/Apple/SignInButton.js @@ -1,127 +1,46 @@ -import React from 'react'; -import { noop } from 'lodash'; -import { withRouter } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; -import { withSession } from 'stores/session'; +import { useSession } from 'stores/session'; +import { useClass } from 'helpers/bem'; -import { initialize, login, enable } from './utils'; +import { signInWithApple } from 'utils/auth/apple'; -@withRouter -@withSession -export default class AppleSignInButton extends React.Component { - constructor(props) { - super(props); - this.state = { - error: null, - }; - } - - componentDidMount() { - this.load(); - document.addEventListener('AppleIDSignInOnSuccess', this.onSuccess); - document.addEventListener('AppleIDSignInOnFailure', this.onFailure); - } - - componentWillUnmount() { - document.removeEventListener('AppleIDSignInOnSuccess', this.onSuccess); - document.removeEventListener('AppleIDSignInOnFailure', this.onFailure); - } - - load = async () => { - await initialize(); - }; +import logo from 'assets/apple-logo-white.svg'; - onSuccess = async (evt) => { - this.props.onVerifyStart(); - const { id_token: token } = evt.detail.authorization; - const { firstName, lastName } = evt.detail.user?.name || {}; - if (this.context.isLoggedIn()) { - await this.enableSignIn(token); - } else { - await this.attemptLogin(token, { - firstName, - lastName, - }); - } - }; +import './apple.less'; - enableSignIn = async (token) => { - this.props.onVerifyStart(); - const user = await enable(token); - this.props.onVerifyStop(); - this.context.updateUser(user); - this.props.onComplete(); - }; +export default function AppleSignInButton(props) { + const { type } = props; + const { onAuthStart, onAuthStop, onError } = props; - attemptLogin = async (token, body) => { - this.props.onVerifyStart(); - const result = await login(token); - this.props.onVerifyStop(); - if (result.token) { - const next = await this.context.authenticate(result.token); - this.props.history.push(next); - } else if (result.next === 'signup') { - this.props.history.push('/signup', { - type: 'apple', - body: { - token, - ...body, - }, - }); - } - }; + const { className, getElementClass } = useClass(`apple-${type}-button`); - onFailure = (evt) => { - this.props.onError?.(evt.detail.error); - }; + const history = useHistory(); + const { authenticate } = useSession(); - render() { - if (this.props.small) { - return this.renderSmall(); - } else { - return this.renderNormal(); + async function onClick() { + try { + onAuthStart(); + const response = await signInWithApple(); + onAuthStop(); + if (response) { + let path = await authenticate(response.token); + if (response.result === 'signup') { + path = '/onboard'; + } + history.push(path); + } + } catch (error) { + onError(error); } } - renderSmall() { - return ( -
- ); - } - - renderNormal() { - return ( -
- ); - } + return ( +
+ + {type === 'signup' && ( +
Sign up with Apple
+ )} +
+ ); } - -AppleSignInButton.defaultProps = { - onVerifyStart: noop, - onVerifyStop: noop, -}; diff --git a/services/web/src/components/Auth/Apple/apple.less b/services/web/src/components/Auth/Apple/apple.less new file mode 100644 index 000000000..e4ff2d38e --- /dev/null +++ b/services/web/src/components/Auth/Apple/apple.less @@ -0,0 +1,34 @@ +.apple { + &-login-button, + &-signup-button { + cursor: pointer; + font-weight: 500; + color: #fff; + background: #000; + } + + &-login-button { + display: flex; + align-items: center; + justify-content: center; + border-radius: 44px; + + &__logo { + position: relative; + top: -1px; + } + } + + &-signup-button { + display: grid; + grid-template-columns: 50px 1fr 50px; + place-items: center; + font-size: 16px; + border-radius: 4px; + height: 2.3em; + + &__logo { + height: 1.2em; + } + } +} diff --git a/services/web/src/components/Auth/Federated.js b/services/web/src/components/Auth/Federated.js index f30237b69..ef2f5be35 100644 --- a/services/web/src/components/Auth/Federated.js +++ b/services/web/src/components/Auth/Federated.js @@ -1,33 +1,62 @@ import React from 'react'; import { Divider } from 'semantic'; -import { canShowGoogleSignin } from 'components/Auth/Google/utils'; -import { canShowAppleSignin } from 'components/Auth/Apple/utils'; +import { canShowGoogleSignin } from 'utils/auth/google'; +import { canShowAppleSignin } from 'utils/auth/apple'; +import { canShowPasskey } from 'utils/auth/passkey'; +import PasskeyButton from './PasskeyButton'; import GoogleButton from './Google/SignInButton'; import AppleButton from './Apple/SignInButton'; -export default function FederatedLogin(props) { +export default function Federated(props) { + const { type } = props; + + const isSignup = type === 'signup'; + const showApple = canShowAppleSignin(); const showGoogle = canShowGoogleSignin(); + const showPasskey = !isSignup && canShowPasskey(); - if (!showApple && !showGoogle) { + if (!showApple && !showGoogle && !showPasskey) { return null; } return ( Or + + {showPasskey && } + {showGoogle && } + {showApple && } + + + ); +} + +function Container(props) { + if (props.type === 'login') { + return (
+ {props.children} +
+ ); + } else { + return ( +
- {showGoogle && } - {showApple && } + {props.children}
- - ); + ); + } } diff --git a/services/web/src/components/Auth/Google/DisableButton.js b/services/web/src/components/Auth/Google/DisableButton.js index b81ce8cef..17d491096 100644 --- a/services/web/src/components/Auth/Google/DisableButton.js +++ b/services/web/src/components/Auth/Google/DisableButton.js @@ -5,7 +5,7 @@ import { Button } from 'semantic'; import { withSession } from 'stores/session'; -import { disable } from './utils'; +import { disable } from 'utils/auth/google'; @withSession export default class DisableButton extends React.Component { diff --git a/services/web/src/components/Auth/Google/SignInButton.js b/services/web/src/components/Auth/Google/SignInButton.js index 6f662afb3..49d72bf6f 100644 --- a/services/web/src/components/Auth/Google/SignInButton.js +++ b/services/web/src/components/Auth/Google/SignInButton.js @@ -1,116 +1,46 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withRouter } from 'react-router-dom'; -import { noop } from 'lodash'; +import { useHistory } from 'react-router-dom'; -import { withSession } from 'stores/session'; +import { useSession } from 'stores/session'; +import { useClass } from 'helpers/bem'; -import { renderButton, login, enable } from './utils'; +import { signInWithGoogle } from 'utils/auth/google'; -@withRouter -@withSession -export default class GoogleSignInButton extends React.Component { - constructor(props) { - super(props); - this.ref = React.createRef(); - } +import logo from 'assets/google-logo.svg'; - componentDidMount() { - this.load(); - } +import './google.less'; - load = async () => { - await renderButton(this.ref.current, { - onAuthenticated: this.onAuthenticated, - ...this.getRenderProps(), - }); - }; +export default function GoogleSignInButton(props) { + const { type } = props; + const { onAuthStart, onAuthStop, onError } = props; - getRenderProps() { - if (this.props.small) { - return { - type: 'icon', - shape: 'circle', - }; - } else { - return { - type: 'standard', - size: 'medium', - }; - } - } + const { className, getElementClass } = useClass(`google-${type}-button`); - onAuthenticated = async (response) => { - const token = response.credential; - if (this.context.isLoggedIn()) { - await this.enableSignIn(token); - } else { - await this.attemptLogin(token); - } - }; - - enableSignIn = async (token) => { - this.props.onVerifyStart(); - const user = await enable(token); - this.props.onVerifyStop(); - this.context.updateUser(user); - this.props.onComplete(); - }; + const history = useHistory(); + const { authenticate } = useSession(); - attemptLogin = async (token) => { - this.props.onVerifyStart(); - const result = await login(token); - this.props.onVerifyStop(); - if (result.token) { - const next = await this.context.authenticate(result.token); - this.props.history.push(next); - } else if (result.next === 'signup') { - this.props.history.push('/signup', { - type: 'google', - body: { - token, - ...result.body, - }, - }); + async function onClick() { + try { + onAuthStart(); + const response = await signInWithGoogle(); + onAuthStop(); + if (response) { + let path = await authenticate(response.token); + if (response.result === 'signup') { + path = '/onboard'; + } + history.push(path); + } + } catch (error) { + onError(error); } - }; - - render() { - if (this.props.small) { - return this.renderSmall(); - } else { - return this.renderNormal(); - } - } - - renderSmall() { - return ( -
- ); } - renderNormal() { - return
; - } + return ( +
+ + {type === 'signup' && ( +
Sign up with Google
+ )} +
+ ); } - -GoogleSignInButton.propTypes = { - small: PropTypes.bool, - onVerifyStart: PropTypes.func, - onVerifyStop: PropTypes.func, - onComplete: PropTypes.func, -}; - -GoogleSignInButton.defaultProps = { - small: false, - onVerifyStart: noop, - onVerifyStop: noop, - onComplete: noop, -}; diff --git a/services/web/src/components/Auth/Google/google.less b/services/web/src/components/Auth/Google/google.less new file mode 100644 index 000000000..b84815224 --- /dev/null +++ b/services/web/src/components/Auth/Google/google.less @@ -0,0 +1,28 @@ +.google { + &-login-button, + &-signup-button { + cursor: pointer; + font-weight: 500; + border: 1px solid #dadada; + } + + &-login-button { + display: flex; + align-items: center; + justify-content: center; + border-radius: 44px; + } + + &-signup-button { + display: grid; + grid-template-columns: 50px 1fr 50px; + place-items: center; + font-size: 16px; + border-radius: 4px; + height: 2.3em; + + &__logo { + height: 1.2em; + } + } +} diff --git a/services/web/src/components/Auth/OptionalPassword.js b/services/web/src/components/Auth/OptionalPassword.js new file mode 100644 index 000000000..55b28f3d4 --- /dev/null +++ b/services/web/src/components/Auth/OptionalPassword.js @@ -0,0 +1,10 @@ +import PasswordField from 'components/form-fields/Password'; + +import { AUTH_TYPE } from 'utils/env'; + +export default function OptionalPassword(props) { + if (AUTH_TYPE !== 'password') { + return null; + } + return ; +} diff --git a/services/web/src/components/Auth/PasskeyButton.js b/services/web/src/components/Auth/PasskeyButton.js new file mode 100644 index 000000000..cf213c93f --- /dev/null +++ b/services/web/src/components/Auth/PasskeyButton.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { noop } from 'lodash'; +import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; + +import { Button } from 'semantic'; + +import { withSession } from 'stores/session'; + +import { login } from 'utils/auth/passkey'; + +@withRouter +@withSession +export default class PasskeyButton extends React.Component { + onClick = async () => { + try { + this.props.onAuthStart(); + const result = await login(); + this.props.onAuthStop(); + + if (result) { + const next = await this.context.authenticate(result.token); + this.props.history.push(next); + } + } catch (error) { + this.props.onAuthError(error); + } + }; + + render() { + return ( +
); diff --git a/services/web/src/components/form-fields/Email.js b/services/web/src/components/form-fields/Email.js index 9aa2387d6..e419578ad 100644 --- a/services/web/src/components/form-fields/Email.js +++ b/services/web/src/components/form-fields/Email.js @@ -5,9 +5,10 @@ import { Form, Input, Message } from 'semantic'; export default class EmailField extends React.Component { render() { - const { error, ...rest } = this.props; + const { error, label, ...rest } = this.props; return ( - + + {label && } + {label && } { + this.props.onChange(evt, { + ...this.props, + value: checked, + }); + }; + + render() { + const { value: accepted } = this.props; + return ( + + + I accept the Terms of Service. + + } + checked={accepted} + onChange={this.onChange} + /> + + ); + } +} diff --git a/services/web/src/docs/App.js b/services/web/src/docs/App.js index e70f13ff5..f7179a7de 100644 --- a/services/web/src/docs/App.js +++ b/services/web/src/docs/App.js @@ -15,7 +15,7 @@ function App() { - + diff --git a/services/web/src/docs/components/properties.less b/services/web/src/docs/components/properties.less index 10312d8cb..c42dcf4ed 100644 --- a/services/web/src/docs/components/properties.less +++ b/services/web/src/docs/components/properties.less @@ -92,7 +92,7 @@ font-size: 14px; &-title { - font-weight: bold; + font-weight: 600; } > pre { diff --git a/services/web/src/docs/pages/AppleAuthentication.mdx b/services/web/src/docs/pages/AppleAuthentication.mdx deleted file mode 100644 index dfc149c94..000000000 --- a/services/web/src/docs/pages/AppleAuthentication.mdx +++ /dev/null @@ -1,33 +0,0 @@ -import Route from 'docs/components/Route'; - -export const title = 'Apple'; -export const group = 'Authentication'; -export const order = 3; - -# Apple Authentication API - -Validates Apple tokens for authentication and user registration. - -## Login - -Verifies a Apple token and authenticates the user. - - - -## Register - -Verifies a Apple token and creates a new user. - - - -## Enable - -Verifies a Apple token and registers a Apple authenticator. - - - -## Disable - -Removes registered Apple authenticator. - - diff --git a/services/web/src/docs/pages/Authentication.mdx b/services/web/src/docs/pages/Authentication.mdx index 1b989530f..1a624fd03 100644 --- a/services/web/src/docs/pages/Authentication.mdx +++ b/services/web/src/docs/pages/Authentication.mdx @@ -4,17 +4,19 @@ export const title = 'Authentication'; # Authentication -The authentication APIs provide different way to handle user authentication. The -following subroutes are provided for each authentication flow: - -- [Password](/docs/password) -- [Passkey](/docs/passkey) -- [Google](/docs/google) -- [Apple](/docs/apple) -- [TOTP](/docs/totp) -- [OTP](/docs/otp) - -Additional routes are provided for general authentication needs: +| Type | Login | Signup | MFA Challenge | MFA Validator | +| ------------------------------------------- | ----- | ------ | ------------- | ------------- | +| [Password](/docs/authentication/password) | Yes | No | Yes | No | +| [Federated](/docs/authentication/federated) | Yes | Yes | No | No | +| [Passkey](/docs/authentication/passkey) | Yes | No | No | No | +| [TOTP](/docs/authentication/totp) | No | No | No | Yes | +| [OTP](/docs/authentication/otp) | Yes | No | No | Yes | + +## Signup + +Note that new user registration often has special requirements and is separate +from this API, however signup allows for an OTP challenge to be validated +[here](/docs/authentication/otp#login). ## Logout diff --git a/services/web/src/docs/pages/FederatedAuthentication.mdx b/services/web/src/docs/pages/FederatedAuthentication.mdx new file mode 100644 index 000000000..33f1cb79d --- /dev/null +++ b/services/web/src/docs/pages/FederatedAuthentication.mdx @@ -0,0 +1,58 @@ +import Route from 'docs/components/Route'; + +export const title = 'Federated'; +export const group = 'Authentication'; +export const order = 2; + +# Federated Authentication + +Works with OAuth2 federated providers, currently +[Google](https://developers.google.com/identity/sign-in/web/sign-in) and +[Apple](https://developer.apple.com/help/account/configure-app-capabilities/about-sign-in-with-apple/). + +Note that federated authentication may result in a signup when authenticating +for the first time. If additional required fields are added to the user model +then this will fail as only `email`, `firstName`, and `lastName` are provided. +In this case your options are: + +1. Make additional fields optional and instead use client side logic to redirect + to an onboarding flow. +2. Remove signup buttons from the registration page and only allow login. Note + that login attempts will be met with an error if the provided `email` does + not exist. + +Note that federated flows are not met with an MFA challenge. + +Note that Google and Apple flows are nearly identical with the exception that +the Google API accepts a `code` that will be exchanged for a token, where the +Apple API accepts a `token` directly. + +## Google + +### Authenticate + +Verifies a Google code and authenticates or creates the user. + + + +### Disable + +Removes registered Google authenticator. + + + +--- + +## Apple + +### Authenticate + +Verifies an Apple token and authenticates or creates the user. + + + +### Disable + +Removes registered Apple authenticator. + + diff --git a/services/web/src/docs/pages/GettingStarted.mdx b/services/web/src/docs/pages/GettingStarted.mdx index d27c38354..abacace45 100644 --- a/services/web/src/docs/pages/GettingStarted.mdx +++ b/services/web/src/docs/pages/GettingStarted.mdx @@ -12,20 +12,20 @@ available for specific endpoints. All communication is enforced over HTTPS (with support for TLS 1.3) and protected by CloudFlare. Using the dashboard API credentials can be managed with a full RBAC permissioning layer. -### URL +## URL Main production URL: {`${API_URL}/`} -### API Key +## API Key Each client using using the api must provide use an API key to identify itself. You can provide your API key via an header (`Api-Key: `): {`curl -H 'Api-Key: ' ${API_URL}/`} -### Authentication +## Authentication JWT is used for all authentication. You can provide your API token in a standard bearer token request (`Authorization: Bearer `) like so: @@ -37,7 +37,7 @@ bearer token request (`Authorization: Bearer `) like so: _When receiving a 401 status code, the client should clear any stored JWT tokens - this will enable authentication reset and expiry behavior_ -### Requests +## Requests A pragmatic RESTful style is enforced on all API calls. GET requests are only used to obtain objects. @@ -54,7 +54,7 @@ Example search: -H 'Content-Type: application/json'`} -### Responses +## Responses A standard successful response envelope has a `data` attribute containing the result. An optional `meta` response can be given to provide supplementary @@ -74,7 +74,7 @@ information such as pagination information: Mutation operations (PATCH and DELETE) may contain a `success` boolean in the response. -### Errors +## Errors Errors are returned as follows: diff --git a/services/web/src/docs/pages/GoogleAuthentication.mdx b/services/web/src/docs/pages/GoogleAuthentication.mdx deleted file mode 100644 index 87fe99ba2..000000000 --- a/services/web/src/docs/pages/GoogleAuthentication.mdx +++ /dev/null @@ -1,33 +0,0 @@ -import Route from 'docs/components/Route'; - -export const title = 'Google'; -export const group = 'Authentication'; -export const order = 2; - -# Google Authentication API - -Validates Google tokens for authentication and user registration. - -## Login - -Verifies a Google token and authenticates the user. - - - -## Register - -Verifies a Google token and creates a new user. - - - -## Enable - -Verifies a Google token and registers a Google authenticator. - - - -## Disable - -Removes registered Google authenticator. - - diff --git a/services/web/src/docs/pages/OtpAuthentication.mdx b/services/web/src/docs/pages/OtpAuthentication.mdx index cac58ccd6..a735b7192 100644 --- a/services/web/src/docs/pages/OtpAuthentication.mdx +++ b/services/web/src/docs/pages/OtpAuthentication.mdx @@ -4,29 +4,32 @@ export const title = 'OTP'; export const group = 'Authentication'; export const order = 5; -# OTP Authentication API +# OTP Authentication -This API performs actions related to passkeys. +An OTP may be either a code or a link sent to the user. -## Send Code +## Send Creates and assigns a new OTP code for the user and sends out an SMS or email. +Either an email or phone number must be provided. - + -## Verify Code +## Login -Verifies the sent code and authenticates the user. Note that this route is also -used for verifying codes in an MFA flow. In this flow the password must also be -recently verified and an error will be thrown if not. +Verifies the sent code and authenticates the user. This will **not** be met with +an MFA challenge. -This route also will set the email or phone verified status. +In addition to validating codes sent with [send](#send), this route verifies +codes in an MFA flow in two scenarios: - +1. The user requests a passwordless signup by either `code` or `link`. +2. The user has an MFA of `email` or `sms` set and is met with an MFA challenge + when attempting to authenticate with a password. -## Register +When in this flow and a password has been set, an error will be thrown if the +password has not been recently verified (5 minutes). -Registers a new user without a password. Note that this route is only used for -OTP login and not MFA. +Authenticating with an OTP will also set the email or phone verified status. - + diff --git a/services/web/src/docs/pages/PasskeyAuthentication.mdx b/services/web/src/docs/pages/PasskeyAuthentication.mdx index 637d1a68e..859f9bb42 100644 --- a/services/web/src/docs/pages/PasskeyAuthentication.mdx +++ b/services/web/src/docs/pages/PasskeyAuthentication.mdx @@ -2,52 +2,47 @@ import Route from 'docs/components/Route'; export const title = 'Passkey'; export const group = 'Authentication'; -export const order = 1; +export const order = 3; -# Passkey Authentication API +# Passkey Authentication -This API performs actions related to passkeys. +This API provides the challenge/response flows allowing the use of passkeys. It +is set up to provide "seamless" authentication, meaning no information +(username/email) is required to authenticate the user. Responses are designed +specifically to work with [SimpleWebAuthn](https://simplewebauthn.dev/). -## Register Generate +Passkeys will **not** be met with MFA challenges as they are inherently more +secure. -Gets options to be passed to the client to create a new passkey in registration -flow. Note that a new user is created in this step. +## Generate Login - +Gets options to be passed to the client to authorize a passkey in a login flow. +The resulting `token` should be passed back into `verify-login`. -## Register Verify + -Verifies client response to create a new passkey in registration flow. +## Verify Login - +Verifies client response to authorize a new passkey in a login flow. -## Login Generate + -Gets options to be passed to the client to authorize a passkey in login flow. +## Generate New Passkey - +Gets options to be passed to the client to create a new passkey. The resulting +`token` should be passed back into `verify-new`. -## Login Verify + -Verifies client response to authorize a new passkey in login flow. +## Verify New Passkey - +Verifies client response and stores a new passkey. -## Enable Generate - -Gets options to be passed to the client to create a new passkey in enable flow. - - - -## Enable Verify - -Verifies client response to authorize a new passkey in enable flow. - - + ## Disable -Removes registered passkey authenticator. Note that passkeys on the client side -must be removed by the user. +Deletes a passkey. Note that passkeys on the client side must be removed by the +user. - + diff --git a/services/web/src/docs/pages/PasswordAuthentication.mdx b/services/web/src/docs/pages/PasswordAuthentication.mdx index 932c013f8..32d197e54 100644 --- a/services/web/src/docs/pages/PasswordAuthentication.mdx +++ b/services/web/src/docs/pages/PasswordAuthentication.mdx @@ -2,28 +2,19 @@ import Route from 'docs/components/Route'; export const title = 'Password'; export const group = 'Authentication'; -export const order = 0; +export const order = 1; -# Password Authentication API +# Password Authentication -This API performs signup, login, and password self-administration such as -resetting of password. - -## Register - -Creates a new user object in the system. This can be initiated without any -authentication. - - - -_Note: If you want to update other attributes on a User, please see the -`PATCH /1/users/me` API call_ +This API performs login and password self-administration such as resetting of +password. ## Login This exchanges a user's credentials (email and password) for a JWT token. This -token can be used for authentication on all subsequent API calls (See Getting -Started). Unless the user has MFA enabled. +token can be used for authentication on all subsequent API calls (See +[Getting Started](/docs/getting-started)). Password login may be met with an MFA +challenge if set by the user. diff --git a/services/web/src/docs/pages/Signup.mdx b/services/web/src/docs/pages/Signup.mdx new file mode 100644 index 000000000..16a2324fe --- /dev/null +++ b/services/web/src/docs/pages/Signup.mdx @@ -0,0 +1,19 @@ +import Route from 'docs/components/Route'; +import VisitedSchemas from 'docs/components/VisitedSchemas'; + +export const title = 'Signup'; + +# Signup + +### Create a new user + +When `type` is `password` and all other requirements are fulfilled, a new user +will be created and a `token` will be returned for +[authentication](/docs/getting-started#authentication). Otherwise, a `challenge` +will be returned with details about the next step. + + + +--- + + diff --git a/services/web/src/docs/pages/TotpAuthentication.mdx b/services/web/src/docs/pages/TotpAuthentication.mdx index 89700d5aa..2787e64aa 100644 --- a/services/web/src/docs/pages/TotpAuthentication.mdx +++ b/services/web/src/docs/pages/TotpAuthentication.mdx @@ -4,10 +4,9 @@ export const title = 'TOTP'; export const group = 'Authentication'; export const order = 4; -# TOTP Authentication API +# Timed OTP Authentication -A TOTP or "Timed OTP" is a code generated by an authenticator app. This API is -used to validate those codes as well as set up a new TOTP authenticator. +A TOTP or "Timed OTP" is a code generated by an authenticator app. ## Login diff --git a/services/web/src/docs/pages/index.js b/services/web/src/docs/pages/index.js index f52447a71..d601f09eb 100644 --- a/services/web/src/docs/pages/index.js +++ b/services/web/src/docs/pages/index.js @@ -4,37 +4,38 @@ import * as GettingStarted from './GettingStarted.mdx'; import * as Authentication from './Authentication.mdx'; import * as PasswordAuthentication from './PasswordAuthentication.mdx'; import * as PasskeyAuthentication from './PasskeyAuthentication.mdx'; -import * as GoogleAuthentication from './GoogleAuthentication.mdx'; -import * as AppleAuthentication from './AppleAuthentication.mdx'; +import * as FederatedAuthentication from './FederatedAuthentication.mdx'; import * as OtpAuthentication from './OtpAuthentication.mdx'; import * as TotpAuthentication from './TotpAuthentication.mdx'; import * as Products from './Products.mdx'; import * as Uploads from './Uploads.mdx'; +import * as Signup from './Signup.mdx'; import * as Shops from './Shops.mdx'; const PAGES = { Authentication, PasswordAuthentication, PasskeyAuthentication, - GoogleAuthentication, - AppleAuthentication, + FederatedAuthentication, TotpAuthentication, OtpAuthentication, GettingStarted, Products, Uploads, + Signup, Shops, }; export const DEFAULT_PAGE_ID = 'getting-started'; const root = {}; -const pagesById = {}; +const pagesByPath = {}; for (let [key, mod] of Object.entries(PAGES)) { const { default: Component, group, hide, ...rest } = mod; const title = mod.title || key; const id = kebabCase(title); + const path = [kebabCase(group), id].filter(Boolean).join('/'); if (hide) { continue; @@ -43,11 +44,12 @@ for (let [key, mod] of Object.entries(PAGES)) { const page = { id, title, + path, Component, ...rest, }; - pagesById[id] = page; + pagesByPath[path] = page; if (group) { root[group] ||= { @@ -65,13 +67,9 @@ for (let [key, mod] of Object.entries(PAGES)) { function getSorted(obj = {}) { const pages = Object.values(obj); pages.sort((a, b) => { - const { order: aOrder, title: aTitle } = a; - const { order: bOrder, title: bTitle } = b; - if (aOrder == null && bOrder != null) { - return 1; - } else if (aOrder != null && bOrder == null) { - return -1; - } else if (aOrder === bOrder) { + const { order: aOrder = 100, title: aTitle } = a; + const { order: bOrder = 100, title: bTitle } = b; + if (aOrder === bOrder) { return aTitle.localeCompare(bTitle); } else { return aOrder - bOrder; @@ -87,4 +85,4 @@ function getSorted(obj = {}) { const sorted = getSorted(root); -export { sorted, pagesById }; +export { sorted, pagesByPath }; diff --git a/services/web/src/docs/screens/ApiDocs/api-docs.less b/services/web/src/docs/screens/ApiDocs/api-docs.less index bb4806979..1b39c13a5 100644 --- a/services/web/src/docs/screens/ApiDocs/api-docs.less +++ b/services/web/src/docs/screens/ApiDocs/api-docs.less @@ -127,4 +127,26 @@ margin-top: -10px; padding-top: 10px; } + + h2[id] { + position: relative; + scroll-margin-top: 15px; + + > a { + display: inline-flex; + visibility: hidden; + margin-left: -0.8em; + padding-right: 0.2em; + text-decoration: none; + + .icon-link::after { + content: '#'; + color: @linkHoverColor; + } + } + + &:hover a { + visibility: visible; + } + } } diff --git a/services/web/src/docs/screens/ApiDocs/index.js b/services/web/src/docs/screens/ApiDocs/index.js index 53f0089da..4c9d2dff7 100644 --- a/services/web/src/docs/screens/ApiDocs/index.js +++ b/services/web/src/docs/screens/ApiDocs/index.js @@ -13,19 +13,18 @@ import { components as markdownComponents } from 'components/Markdown'; import DocsPath from '../../components/DocsPath'; -import { DEFAULT_PAGE_ID, pagesById, sorted } from '../../pages'; +import { DEFAULT_PAGE_ID, pagesByPath, sorted } from '../../pages'; import './api-docs.less'; @bem @screen export default class ApiDocs extends React.Component { - static layout = 'portal'; static contextType = DocsContext; componentDidMount() { - const { id } = this.props.match.params; - if (!id) { + const path = this.getDocsPath(); + if (!path) { this.props.history.replace(`/docs/${DEFAULT_PAGE_ID}`); } else { this.checkScroll(); @@ -33,13 +32,18 @@ export default class ApiDocs extends React.Component { } componentDidUpdate(lastProps) { - const { pathname } = this.props.location; - const { pathname: lastPathname } = lastProps.location; - if (pathname !== lastPathname) { + const { pathname, hash } = this.props.location; + const { pathname: lastPathname, hash: lastHash } = lastProps.location; + if (pathname !== lastPathname || hash !== lastHash) { this.checkScroll(true); } } + getDocsPath() { + const { pathname } = this.props.location; + return pathname.split('/').slice(2).filter(Boolean).join('/'); + } + // TODO: This is hacky, fix later checkScroll(update) { const { hash } = this.props.location; @@ -48,7 +52,6 @@ export default class ApiDocs extends React.Component { if (el) { el.scrollIntoView({ behavior: 'smooth', - block: 'start', }); } } else if (update) { @@ -82,13 +85,19 @@ export default class ApiDocs extends React.Component { {this.renderSidebarLink(page)} {pages.length > 0 && (
    - {pages.map((subpage) => { - return ( - - {this.renderSidebarLink(subpage)} - - ); - })} + {pages + .filter((subpage) => { + const [prefix] = subpage.path.split('/'); + const { pathname } = this.props.location; + return pathname.startsWith(`/docs/${prefix}`); + }) + .map((subpage) => { + return ( + + {this.renderSidebarLink(subpage)} + + ); + })}
)} @@ -101,12 +110,13 @@ export default class ApiDocs extends React.Component { } renderSidebarLink(page) { - const { id, title } = page; - const path = `/docs/${id}`; - const isFocused = this.props.history.location.pathname === path; + const { title, path } = page; + const { pathname } = this.props.location; + const full = `/docs/${path}`; + const isFocused = pathname === full; return ( import('./App')); -const AuthApp = React.lazy(() => import('./auth/App')); +const AuthApp = React.lazy(() => import('./AuthApp')); const DocsApp = React.lazy(() => import('./docs/App')); +const OnboardApp = React.lazy(() => import('./OnboardApp')); function AppSwitch() { const { user } = useSession(); @@ -52,6 +53,7 @@ const Wrapper = () => ( }> + diff --git a/services/web/src/layouts/Sidebar/sidebar.less b/services/web/src/layouts/Sidebar/sidebar.less index 9da3979a1..1586305c8 100644 --- a/services/web/src/layouts/Sidebar/sidebar.less +++ b/services/web/src/layouts/Sidebar/sidebar.less @@ -9,18 +9,18 @@ .sidebar-layout { &-menu { position: fixed; - z-index: 2; + z-index: 1000; top: 0; left: 0; width: @menuWidth; - height: 100vh; + height: 100dvh; padding: 15px 0; background-color: @mainMenuBackground; border-right: 1px solid rgba(0, 0, 0, 0.05); .nocturnal-theme & { background-color: @nocturnalMainMenuBackground; - border-right-color: rgba(255,255,255,0.1); + border-right-color: rgba(255, 255, 255, 0.1); } &-wrap { @@ -94,7 +94,7 @@ &:hover { color: @nocturnalMutedTextColor; - background-color: rgba(255,255,255,0.05); + background-color: rgba(255, 255, 255, 0.05); } &.active { @@ -135,7 +135,7 @@ border-bottom-color: @nocturnalBorderColor; &:hover { - border-color: rgba(255,255,255,.2); + border-color: rgba(255, 255, 255, 0.2); } } } @@ -179,7 +179,7 @@ .nocturnal-theme & { background: @nocturnalMainMenuBackground; - border-bottom-color: rgba(255,255,255,0.1); + border-bottom-color: rgba(255, 255, 255, 0.1); } } @@ -209,7 +209,7 @@ } .nocturnal-theme & { - background-color: rgba(mix(#000, @nocturnalMainMenuBackground, 50%), .7); + background-color: rgba(mix(#000, @nocturnalMainMenuBackground, 50%), 0.7); } } } diff --git a/services/web/src/screens/Auth/AcceptInvite/Form.js b/services/web/src/screens/Auth/AcceptInvite/Form.js index 7800585d2..431d42265 100644 --- a/services/web/src/screens/Auth/AcceptInvite/Form.js +++ b/services/web/src/screens/Auth/AcceptInvite/Form.js @@ -2,14 +2,13 @@ import React from 'react'; import { Form, Button } from 'semantic'; import ErrorMessage from 'components/ErrorMessage'; +import OptionalPassword from 'components/Auth/OptionalPassword'; export default (props) => { const { payload, error, loading } = props; const [firstName, setFirstName] = React.useState(''); const [lastName, setLastName] = React.useState(''); const [password, setPassword] = React.useState(''); - const [touched, setTouched] = React.useState(false); - const [accepted, setAccepted] = React.useState(false); // Note that the disabled email field here is only to ensure // that browsers don't incorrectly save the last name as the @@ -19,14 +18,8 @@ export default (props) => { return (
{ - setTouched(true); - if (!accepted) { - return; - } - props.onSubmit({ firstName, lastName, @@ -57,7 +50,7 @@ export default (props) => { autoComplete="username" disabled /> - { onChange={(e, { value }) => setPassword(value)} error={error?.hasField?.('password')} /> - - I agree to the{' '} - - Terms of Service - - - } - onChange={(e, { checked }) => setAccepted(checked)} - />