feat: add configuration to disable user registration

This commit is contained in:
Matthew H. Irby
2023-08-07 11:43:15 -04:00
committed by GitHub
parent 54941f9dcf
commit fe034c1fcc
28 changed files with 353 additions and 4 deletions

View File

@ -55,3 +55,31 @@ jobs:
if: always()
working-directory: ./deploy/docker-compose
run: docker compose -f "quickstart.yaml" down
nosignup:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Copy config
working-directory: ./deploy/docker-compose
run: cp config-disable-signup.yaml config.yaml
- name: Start containers
working-directory: ./deploy/docker-compose
run: docker compose -f "quickstart.yaml" up -d --build
- name: Install dependencies
working-directory: ./e2e
run: |
npm install
npx playwright install chromium
- name: Run tests
working-directory: ./e2e
run: npm run 'test:nosignup'
- name: Stop containers
if: always()
working-directory: ./deploy/docker-compose
run: docker compose -f "quickstart.yaml" down

View File

@ -140,6 +140,10 @@ func DefaultConfig() *Config {
Interval: 1 * time.Minute,
},
},
Account: Account{
AllowDeletion: false,
AllowSignup: true,
},
}
}
@ -626,4 +630,5 @@ type LoggerConfig struct {
type Account struct {
// Allow Deletion indicates if a user can perform self-service deletion
AllowDeletion bool `yaml:"allow_deletion" json:"allow_deletion,omitempty" koanf:"allow_deletion" jsonschema:"default=false"`
AllowSignup bool `yaml:"allow_signup" json:"allow_signup,omitempty" koanf:"allow_signup" jsonschema:"default=true"`
}

View File

@ -15,6 +15,12 @@ func TestDefaultConfigNotEnoughForValidation(t *testing.T) {
}
}
func TestDefaultConfigAccountParameters(t *testing.T) {
cfg := DefaultConfig()
assert.Equal(t, cfg.Account.AllowDeletion, false)
assert.Equal(t, cfg.Account.AllowSignup, true)
}
func TestParseValidConfig(t *testing.T) {
configPath := "./config.yaml"
cfg, err := Load(&configPath)

View File

@ -579,4 +579,11 @@ account:
# Default: false
#
allow_deletion: false
## allow_signup
#
# Users are able to sign up new accounts.
#
# Default: true
#
allow_signup: true
```

View File

@ -38,6 +38,10 @@ type UserCreateBody struct {
}
func (h *UserHandler) Create(c echo.Context) error {
if !h.cfg.Account.AllowSignup {
return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("account signup is disabled"))
}
var body UserCreateBody
if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil {
return dto.ToHttpError(err)

View File

@ -220,6 +220,27 @@ func (s *userSuite) TestUserHandler_Create_EmailMissing() {
s.Equal(http.StatusBadRequest, rec.Code)
}
func (s *userSuite) TestUserHandler_Create_AccountCreationDisabled() {
if testing.Short() {
s.T().Skip("skipping test in short mode.")
}
testConfig := test.DefaultConfig
testConfig.Account.AllowSignup = false
e := NewPublicRouter(&testConfig, s.Storage, nil)
body := UserCreateBody{Email: "jane.doe@example.com"}
bodyJson, err := json.Marshal(body)
s.NoError(err)
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(bodyJson))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
s.Equal(http.StatusForbidden, rec.Code)
}
func (s *userSuite) TestUserHandler_Get() {
if testing.Short() {
s.T().Skip("skipping test in short mode.")

View File

@ -9,6 +9,11 @@
"type": "boolean",
"description": "Allow Deletion indicates if a user can perform self-service deletion",
"default": false
},
"allow_signup": {
"type": "boolean",
"description": "Allow Signup indicates if a user can sign up with service",
"default": true
}
},
"additionalProperties": false,
@ -568,4 +573,4 @@
"description": "WebauthnSettings defines the settings for the webauthn authentication mechanism"
}
}
}
}

View File

@ -36,4 +36,8 @@ var DefaultConfig = config.Config{
Service: config.Service{
Name: "Test",
},
Account: config.Account{
AllowSignup: true,
AllowDeletion: false,
},
}

View File

@ -0,0 +1,31 @@
database:
user: hanko
password: hanko
host: postgresd
port: 5432
dialect: postgres
passcode:
email:
from_address: no-reply@hanko.io
smtp:
host: "mailslurper"
port: "2500"
secrets:
keys:
- abcedfghijklmnopqrstuvwxyz
service:
name: Hanko Authentication Service
webauthn:
relying_party:
origins:
- "http://localhost:8888"
session:
cookie:
secure: false # is needed for safari, because safari does not store secure cookies on localhost
server:
public:
cors:
allow_origins:
- "http://localhost:8888"
account:
allow_signup: false

View File

@ -125,6 +125,7 @@
<tr class="deep-level-0">
<td class="name"><code>allow_deletion</code></td>
<td class="name"><code>allow_signup</code></td>
<td class="type">
@ -244,4 +245,4 @@
</body>
</html>
</html>

View File

@ -819,6 +819,7 @@ paths:
/users:
post:
summary: 'Create a user'
description: Used to create a new user. To disable this endpoint, `config.account.allow_signup` must be set to false.
operationId: createUser
tags:
- User Management
@ -842,6 +843,8 @@ paths:
$ref: '#/components/schemas/CreateUserResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'500':
@ -1097,6 +1100,9 @@ components:
allow_deletion:
description: Indicates the user account can be deleted by the current user.
type: boolean
allow_signup:
description: Indicates users are able to create new accounts.
type: boolean
CookieSession:
type: string
description: Value `<JWT>` is a [JSON Web Token](https://www.rfc-editor.org/rfc/rfc7519.html)

View File

@ -6,6 +6,8 @@ import { RegisterAuthenticator } from "../pages/RegisterAuthenticator.js";
import { SecuredContent } from "../pages/SecuredContent.js";
import { LoginPassword } from "../pages/LoginPassword.js";
import { RegisterPassword } from "../pages/RegisterPassword.js";
import { LoginEmailNoSignup } from "../pages/LoginEmailNoSignUp.js";
import { NoAccountFound } from "../pages/NoAccountFound.js";
import { MailSlurper } from "../helper/MailSlurper.js";
import * as Matchers from "../helper/Matchers.js";
import { Error } from "../pages/Error.js";
@ -15,6 +17,8 @@ import Setup from "../helper/Setup.js";
export type Pages = {
errorPage: Error;
loginEmailPage: LoginEmail;
loginEmailNoSignupPage: LoginEmailNoSignup;
noAccountFoundPage: NoAccountFound,
registerConfirmPage: RegisterConfirm;
loginPasscodePage: LoginPasscode;
loginPasswordPage: LoginPassword;
@ -59,6 +63,19 @@ export const test = base.extend<TestOptions & Pages>({
await use(loginEmailPage);
},
loginEmailNoSignupPage: async ({ baseURL, page }, use) => {
await Promise.all([
page.waitForResponse(Endpoints.API.WELL_KNOWN_CONFIG),
page.goto(baseURL!),
]);
const loginEmailPageNoSignup: LoginEmailNoSignup = new LoginEmailNoSignup(page);
await use(loginEmailPageNoSignup);
},
noAccountFoundPage: async ({ page }, use) => {
await use(new NoAccountFound(page));
},
registerConfirmPage: async ({ page }, use) => {
await use(new RegisterConfirm(page));
},

View File

@ -8,6 +8,7 @@
"scripts": {
"test:nopw": "npx playwright test passwordless common",
"test:pw": "npx playwright test passwords common",
"test:nosignup": "npx playwright test nosignup",
"test:report": "npx playwright show-report"
},
"repository": {

View File

@ -0,0 +1,38 @@
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./BasePage.js";
import Endpoints from "../helper/Endpoints.js";
export class LoginEmailNoSignup extends BasePage {
readonly emailInput: Locator;
readonly continueButton: Locator;
readonly signInPasskeyButton: Locator;
readonly headline: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.locator("input[name=email]");
this.continueButton = page.locator("button[type=submit]", {
hasText: "Continue",
});
this.signInPasskeyButton = page.locator("button[type=submit]", {
hasText: "Sign in with a passkey",
});
this.headline = page.locator("h1", { hasText: "Sign in" });
}
async continueUsingEmail(email: string) {
await this.emailInput.fill(email);
await Promise.all([
this.page.waitForResponse(Endpoints.API.USER),
this.continueButton.click(),
]);
}
async signInWithPasskey() {
await Promise.all([
this.page.waitForResponse(Endpoints.API.WEBAUTHN_LOGIN_INITIALIZE),
this.page.waitForResponse(Endpoints.API.WEBAUTHN_LOGIN_FINALIZE),
this.signInPasskeyButton.click(),
]);
}
}

View File

@ -0,0 +1,31 @@
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./BasePage.js";
import { expect } from "../fixtures/Pages.js";
export class NoAccountFound extends BasePage {
readonly backLink: Locator;
readonly signUpButton: Locator;
readonly headline: Locator;
constructor(page: Page) {
super(page);
this.backLink = page.locator("button", { hasText: "Back" });
this.signUpButton = page.locator("button[type=submit]", {
hasText: "Sign up",
});
this.headline = page.locator("h1", { hasText: "No account found" });
}
async assertSignupButtonNotVisible() {
await expect(this.signUpButton).not.toBeVisible();
}
async assertNoAccountFoundText(email: string) {
const text = this.page.locator("p", {hasText: `No account exists for "${email}".`});
await expect(text).toBeVisible();
}
async back() {
await this.backLink.click();
}
}

View File

@ -0,0 +1,34 @@
import { test, expect } from "../fixtures/Pages.js";
import { faker } from "@faker-js/faker";
test.describe("@nosignup", () => {
test("Login with account not found", async ({
loginEmailNoSignupPage,
noAccountFoundPage
}) => {
const email = faker.internet.email();
await test.step("When I visit the baseURL, the LoginEmailNoSignup page should be shown", async () => {
await expect(loginEmailNoSignupPage.headline).toBeVisible();
await expect(loginEmailNoSignupPage.signInPasskeyButton).toBeVisible();
});
await test.step("And when I submit an email address", async () => {
await loginEmailNoSignupPage.continueUsingEmail(email);
});
await test.step("No account should be found", async () => {
await noAccountFoundPage.assertNoAccountFoundText(email);
});
await test.step("Signup button should not be visible", async() => {
await noAccountFoundPage.assertSignupButtonNotVisible();
});
await test.step("Navigating back should take me back to LoginEmailNoSignup page", async () => {
await noAccountFoundPage.back();
await expect(loginEmailNoSignupPage.headline).toBeVisible();
await expect(loginEmailNoSignupPage.signInPasskeyButton).toBeVisible();
});
});
});

View File

@ -4,6 +4,7 @@ export const de: Translation = {
headlines: {
error: "Ein Fehler ist aufgetreten",
loginEmail: "Anmelden / Registrieren",
loginEmailNoSignup: "Anmelden",
loginFinished: "Login erfolgreich",
loginPasscode: "Passcode eingeben",
loginPassword: "Passwort eingeben",
@ -24,6 +25,7 @@ export const de: Translation = {
createdAt: "Erstellt am",
connectedAccounts: "Verbundene Konten",
deleteAccount: "Konto löschen",
accountNotFound: "Konto nicht gefunden"
},
texts: {
enterPasscode:
@ -57,6 +59,8 @@ export const de: Translation = {
"Löschen Sie diesen Passkey aus Ihrem Konto. Beachten Sie, dass der Passkey noch auf Ihren Geräten vorhanden ist und auch dort gelöscht werden muss.",
deleteAccount:
"Sind Sie sicher, dass Sie Ihr Konto löschen wollen? Alle Daten werden sofort gelöscht und können nicht wiederhergestellt werden.",
noAccountExists:
'Es existiert kein Konto für "{emailAddress}".',
},
labels: {
or: "oder",

View File

@ -4,6 +4,7 @@ export const en: Translation = {
headlines: {
error: "An error has occurred",
loginEmail: "Sign in or sign up",
loginEmailNoSignup: "Sign in",
loginFinished: "Login successful",
loginPasscode: "Enter passcode",
loginPassword: "Enter password",
@ -24,6 +25,7 @@ export const en: Translation = {
createdAt: "Created at",
connectedAccounts: "Connected accounts",
deleteAccount: "Delete account",
accountNotFound: "Account not found",
},
texts: {
enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".',
@ -55,6 +57,8 @@ export const en: Translation = {
"Delete this passkey from your account. Note that the passkey will still exist on your devices and needs to be deleted there as well.",
deleteAccount:
"Are you sure you want to delete this account? All data will be deleted immediately and cannot be recovered.",
noAccountExists:
'No account exists for "{emailAddress}".',
},
labels: {
or: "or",

View File

@ -4,6 +4,7 @@ export const fr: Translation = {
headlines: {
error: "Une erreur s'est produite",
loginEmail: "Se connecter ou s'inscrire",
loginEmailNoSignup: "Se connecter",
loginFinished: "Connexion réussie",
loginPasscode: "Entrez le code d'accès",
loginPassword: "Entrez le mot de passe",
@ -24,6 +25,7 @@ export const fr: Translation = {
createdAt: "Créé le",
connectedAccounts: "Comptes connectés",
deleteAccount: "Supprimer le compte",
accountNotFound: "Compte non trouvé",
},
texts: {
enterPasscode:
@ -57,6 +59,8 @@ export const fr: Translation = {
"Supprimez cette clé d'identification de votre compte. Notez que la clé d'identification continuera d'exister sur vos appareils et devra également y être supprimée.",
deleteAccount:
"Êtes-vous sûr de vouloir supprimer ce compte ? Toutes les données seront supprimées immédiatement et ne pourront pas être récupérées.",
noAccountExists:
'Aucun compte n\'existe pour "{emailAddress}".',
},
labels: {
or: "ou",

View File

@ -7,7 +7,9 @@ export interface Translations {
export interface Translation {
headlines: {
error: string;
accountNotFound: string;
loginEmail: string;
loginEmailNoSignup: string;
loginFinished: string;
loginPasscode: string;
loginPassword: string;
@ -33,6 +35,7 @@ export interface Translation {
enterPasscode: string;
setupPasskey: string;
createAccount: string;
noAccountExists: string;
passwordFormatHint: string;
manageEmails: string;
changePassword: string;

View File

@ -0,0 +1,45 @@
import { Fragment } from "preact";
import { useContext } from "preact/compat";
import { TranslateContext } from "@denysvuika/preact-translate";
import Content from "../components/wrapper/Content";
import Footer from "../components/wrapper/Footer";
import ErrorMessage from "../components/error/ErrorMessage";
import Paragraph from "../components/paragraph/Paragraph";
import Headline1 from "../components/headline/Headline1";
import Link from "../components/link/Link";
interface Props {
emailAddress: string;
onBack: () => void;
}
const AccountNotFoundPage = ({
emailAddress,
onBack,
}: Props) => {
const { t } = useContext(TranslateContext);
const onBackClick = (event: Event) => {
event.preventDefault();
onBack();
};
return (
<Fragment>
<Content>
<Headline1>{t("headlines.accountNotFound")}</Headline1>
<Paragraph>{t("texts.noAccountExists", { emailAddress })}</Paragraph>
</Content>
<Footer>
<span hidden />
<Link onClick={onBackClick}>
{t("labels.back")}
</Link>
</Footer>
</Fragment>
);
};
export default AccountNotFoundPage;

View File

@ -37,6 +37,7 @@ import LoginPasswordPage from "./LoginPasswordPage";
import RegisterPasskeyPage from "./RegisterPasskeyPage";
import RegisterPasswordPage from "./RegisterPasswordPage";
import ErrorPage from "./ErrorPage";
import AccountNotFoundPage from "./AccountNotFoundPage";
interface Props {
emailAddress?: string;
@ -199,6 +200,15 @@ const LoginEmailPage = (props: Props) => {
]
);
const renderAccountNotFound = useCallback(
() => setPage(<AccountNotFoundPage emailAddress={emailAddress} onBack={onBackHandler}/>),
[
emailAddress,
onBackHandler,
setPage
]
);
const loginWithEmailAndWebAuthn = () => {
let _userInfo: UserInfo;
let _webauthnFinalizedResponse: WebauthnFinalized;
@ -237,7 +247,13 @@ const LoginEmailPage = (props: Props) => {
})
.catch((e) => {
if (e instanceof NotFoundError) {
renderRegistrationConfirm();
if (config.account.allow_signup) {
renderRegistrationConfirm();
return;
}
renderAccountNotFound();
return;
}
@ -397,7 +413,7 @@ const LoginEmailPage = (props: Props) => {
return (
<Content>
<Headline1>{t("headlines.loginEmail")}</Headline1>
<Headline1>{config.account.allow_signup ? t("headlines.loginEmail") : t("headlines.loginEmailNoSignup")}</Headline1>
<ErrorMessage error={error} />
<Form onSubmit={onEmailSubmit}>
<Input

1
frontend/frontend-sdk/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
coverage

View File

@ -83,6 +83,7 @@ export type {
import {
HankoError,
ConflictError,
ForbiddenError,
EmailAddressAlreadyExistsError,
InvalidPasswordError,
InvalidPasscodeError,
@ -103,6 +104,7 @@ import {
export {
HankoError,
ConflictError,
ForbiddenError,
EmailAddressAlreadyExistsError,
InvalidPasswordError,
InvalidPasscodeError,

View File

@ -29,9 +29,11 @@ export interface EmailConfig {
* @category SDK
* @subcategory DTO
* @property {boolean} allow_deletion - Indicates the current user is allowed to delete the account.
* @property {boolean} allow_signup - Indicates the current user is allowed to sign up.
*/
export interface AccountConfig {
allow_deletion: boolean;
allow_signup: boolean;
}
/**

View File

@ -221,6 +221,21 @@ class UnauthorizedError extends HankoError {
}
}
/**
* A 'ForbiddenError' occurs when the user is not allowed to perform the requested action.
*
* @category SDK
* @subcategory Errors
* @extends {HankoError}
*/
class ForbiddenError extends HankoError {
// eslint-disable-next-line require-jsdoc
constructor(cause?: Error) {
super("Forbidden error", "forbidden", cause);
Object.setPrototypeOf(this, ForbiddenError.prototype);
}
}
/**
* A 'UserVerificationError' occurs when the user verification requirements
* for a WebAuthn ceremony are not met.
@ -306,6 +321,7 @@ export {
NotFoundError,
TooManyRequestsError,
UnauthorizedError,
ForbiddenError,
UserVerificationError,
MaxNumOfEmailAddressesReachedError,
EmailAddressAlreadyExistsError,

View File

@ -4,6 +4,7 @@ import {
NotFoundError,
TechnicalError,
UnauthorizedError,
ForbiddenError,
} from "../Errors";
import { Client } from "./Client";
@ -55,6 +56,8 @@ class UserClient extends Client {
if (response.status === 409) {
throw new ConflictError();
} if (response.status === 403) {
throw new ForbiddenError();
} else if (!response.ok) {
throw new TechnicalError();
}

View File

@ -2,6 +2,7 @@ import {
ConflictError,
NotFoundError,
TechnicalError,
ForbiddenError,
UserClient,
} from "../../../src";
import { Response } from "../../../src/lib/client/HttpClient";
@ -179,6 +180,15 @@ describe("UserClient.create()", () => {
await expect(user).rejects.toThrow(ConflictError);
});
it("should throw error when signup is disabled", async () => {
const response = new Response(new XMLHttpRequest());
response.status = 403;
jest.spyOn(userClient.client, "post").mockResolvedValue(response);
const user = userClient.create(email);
await expect(user).rejects.toThrow(ForbiddenError);
});
it("should throw error if API response is not ok (no 2xx, no 4xx)", async () => {
const response = new Response(new XMLHttpRequest());
jest.spyOn(userClient.client, "post").mockResolvedValue(response);