Skip to content

Commit

Permalink
Password-protected Tokens (#300)
Browse files Browse the repository at this point in the history
* feat: add password protection to templates

* test: add tests

* fix/test: add tests, make fixes

* fix: backbone API

* feat: change error message

* refactor: nameof, toString

* feat/test: error message, add validation test

* fix: remove only

* chore: bump backbone

Co-authored-by: Julian König <[email protected]>

* fix: missing password pass

* refactor: wrong variable name

* refactor: review comments

* chore: upgrade backbone and adapt client

* feat: add password-protection to tokens

* test: add anonymous tests

* fix: pass password

* fix: error in test

* chore: build schemas

* feat: hash passwords

* feat: add separate pin

* feat: enhance password type

* refactor: align error messages

* fix: more enhancing password type

* test: adapt tests

* test: reference adaptations

* wip

* refactor/feat: review comments

* feat: add transport empty string validation

* test: add tests

* fix: schemas, error codes

* fix: add PINs when loading

* feat: add loading validation, tests

* test: add validations

* fix: test errors

* fix/feat: add loading validation, fix tests

* test: fix tests

* refactor: no PIN validation in loading schema

* test: fix copy-paste error

* refactor: use schemas for empty string validation

* feat: use salt

* refactor/test: reference adaptations

* feat: adapt automatic version setting

* fix: version in reference

* feat: remove salt from dto

* test: refactor tests

* feat: add/adapt salt validation

* refactor/test: validations

* test: fix ids

* chore: bump backbone

* feat: remove version

* test: fix salt test

* chore: transport PR comments

* chore: runtime PR comments

* fix/refactor: more stuff

* test: correct check

* test: fix used function

* feat: add transport setting validation

* refactor: import

* chore: build schemas

* refactor: passwordinfo

* fix: cleanup

* test: cleanup

* refactor/fix: use password info derivatives

* refactor: remove unused error

* test: fix error names in tests

* feat: password error message

* refactor: test names and content, class usage

* feat: runtime interface with flag

* test: adapt tests

* chore: schemaas

* fix: mapping, tests

* refactor: simplify object access

* refactor: rename passwordProtection

* refactor: naming

* chore: move business logic to object

* refactor: use min

* fix: tests

* feat: passwordIsPin true or undefined

* chore: build schemas

* chore: remove unused method

* test: fix tests

* fix: ability to truncate

* refactor: add fromTruncted

* feat: adapt tokens to templates

* feat: error message mentions wrong password

* fix/feat: add missing validations, type cleanup

* test: adapt token controller tests

* test: adapt runtime tests

* chore: schemas

* fix: tests, loading token

* fix: tests, inheritance

* refactor: move expiresAt to schema validator

* refactor: move passwordProtection into schema validator

* fix: use lodash

* refactor: line breaks, comments, lodash

* fix: validator

* test: protect token

* test: remove redundant tests

* test: add reference test

* chore: build schemas

* test: fix error messages

* test: fix error code

* test: fix error messages

* chore: bump backbone

* fix: missing password

* refactor: namings, restructurings

* refactor: generic input validator with password only

* refactor: separate test files

* test: test names, some fixes

* refactor: tokenAndTemplateCreationValidator

* refactor: improve tokenAndTemplateCreationValidator

* chore: build schemas

* refactor: invalidPropertyValue

* test: adapt tests

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Julian König <[email protected]>
Co-authored-by: Julian König <[email protected]>
  • Loading branch information
4 people authored Nov 29, 2024
1 parent 61a8882 commit 2241e42
Show file tree
Hide file tree
Showing 35 changed files with 1,219 additions and 290 deletions.
2 changes: 1 addition & 1 deletion .dev/compose.backbone.env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
BACKBONE_VERSION=6.19.1
BACKBONE_VERSION=6.20.0
4 changes: 4 additions & 0 deletions packages/runtime/src/types/transport/TokenDTO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export interface TokenDTO {
createdAt: string;
expiresAt: string;
forIdentity?: string;
passwordProtection?: {
password: string;
passwordIsPin?: true;
};
truncatedReference: string;
isEphemeral: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TokenMapper } from "../../transport/tokens/TokenMapper";

export interface LoadPeerTokenAnonymousRequest {
reference: TokenReferenceString;
password?: string;
}

class Validator extends SchemaValidator<LoadPeerTokenAnonymousRequest> {
Expand All @@ -24,7 +25,7 @@ export class LoadPeerTokenAnonymousUseCase extends UseCase<LoadPeerTokenAnonymou
}

protected async executeInternal(request: LoadPeerTokenAnonymousRequest): Promise<Result<TokenDTO>> {
const createdToken = await this.anonymousTokenController.loadPeerTokenByTruncated(request.reference);
const createdToken = await this.anonymousTokenController.loadPeerTokenByTruncated(request.reference, request.password);
return Result.ok(TokenMapper.toTokenDTO(createdToken, true));
}
}
11 changes: 7 additions & 4 deletions packages/runtime/src/useCases/common/RuntimeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ class General {
public cacheEmpty(entityName: string | Function, id: string) {
return new ApplicationError("error.runtime.cacheEmpty", `The cache of ${entityName instanceof Function ? entityName.name : entityName} with id '${id}' is empty.`);
}

public invalidPin(): ApplicationError {
return new ApplicationError("error.runtime.validation.invalidPin", "The PIN is invalid. It must consist of 4 to 16 digits from 0 to 9.");
}
}

class Serval {
Expand Down Expand Up @@ -88,6 +84,13 @@ class RelationshipTemplates {
);
}

public passwordProtectionMustBeInherited(): ApplicationError {
return new ApplicationError(
"error.runtime.relationshipTemplates.passwordProtectionMustBeInherited",
"If a RelationshipTemplate has password protection, Tokens created from it must have the same password protection."
);
}

public cannotCreateTokenForPeerTemplate(): ApplicationError {
return new ApplicationError("error.runtime.relationshipTemplates.cannotCreateTokenForPeerTemplate", "You cannot create a Token for a peer RelationshipTemplate.");
}
Expand Down
97 changes: 97 additions & 0 deletions packages/runtime/src/useCases/common/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const LoadPeerTokenAnonymousRequest: any = {
"properties": {
"reference": {
"$ref": "#/definitions/TokenReferenceString"
},
"password": {
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -20553,6 +20556,9 @@ export const LoadItemFromTruncatedReferenceRequest: any = {
"$ref": "#/definitions/RelationshipTemplateReferenceString"
}
]
},
"password": {
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -21295,6 +21301,23 @@ export const CreateTokenForFileRequest: any = {
},
"forIdentity": {
"$ref": "#/definitions/AddressString"
},
"passwordProtection": {
"type": "object",
"properties": {
"password": {
"type": "string",
"minLength": 1
},
"passwordIsPin": {
"type": "boolean",
"const": true
}
},
"required": [
"password"
],
"additionalProperties": false
}
},
"required": [
Expand Down Expand Up @@ -21333,6 +21356,23 @@ export const CreateTokenQRCodeForFileRequest: any = {
},
"forIdentity": {
"$ref": "#/definitions/AddressString"
},
"passwordProtection": {
"type": "object",
"properties": {
"password": {
"type": "string",
"minLength": 1
},
"passwordIsPin": {
"type": "boolean",
"const": true
}
},
"required": [
"password"
],
"additionalProperties": false
}
},
"required": [
Expand Down Expand Up @@ -21557,6 +21597,9 @@ export const GetOrLoadFileRequest: any = {
"$ref": "#/definitions/FileReferenceString"
}
]
},
"password": {
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -22486,6 +22529,23 @@ export const CreateTokenForOwnTemplateRequest: any = {
},
"forIdentity": {
"$ref": "#/definitions/AddressString"
},
"passwordProtection": {
"type": "object",
"properties": {
"password": {
"type": "string",
"minLength": 1
},
"passwordIsPin": {
"type": "boolean",
"const": true
}
},
"required": [
"password"
],
"additionalProperties": false
}
},
"required": [
Expand Down Expand Up @@ -22524,6 +22584,23 @@ export const CreateTokenQRCodeForOwnTemplateRequest: any = {
},
"forIdentity": {
"$ref": "#/definitions/AddressString"
},
"passwordProtection": {
"type": "object",
"properties": {
"password": {
"type": "string",
"minLength": 1
},
"passwordIsPin": {
"type": "boolean",
"const": true
}
},
"required": [
"password"
],
"additionalProperties": false
}
},
"required": [
Expand Down Expand Up @@ -22747,6 +22824,23 @@ export const CreateOwnTokenRequest: any = {
},
"forIdentity": {
"$ref": "#/definitions/AddressString"
},
"passwordProtection": {
"type": "object",
"properties": {
"password": {
"type": "string",
"minLength": 1
},
"passwordIsPin": {
"type": "boolean",
"const": true
}
},
"required": [
"password"
],
"additionalProperties": false
}
},
"required": [
Expand Down Expand Up @@ -22923,6 +23017,9 @@ export const LoadPeerTokenRequest: any = {
},
"ephemeral": {
"type": "boolean"
},
"password": {
"type": "string"
}
},
"required": [
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/useCases/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./RuntimeErrors";
export * from "./SchemaRepository";
export * from "./UseCase";
export * from "./validation/SchemaValidator";
export * from "./validation/TokenAndTemplateCreationValidator";
export * from "./validation/ValidatableStrings";
export * from "./validation/ValidationFailure";
export * from "./validation/ValidationResult";
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { CoreDate } from "@nmshd/core-types";
import { RuntimeErrors } from "../RuntimeErrors";
import { JsonSchema } from "../SchemaRepository";
import { SchemaValidator } from "./SchemaValidator";
import { ISO8601DateTimeString } from "./ValidatableStrings";
import { ValidationFailure } from "./ValidationFailure";
import { ValidationResult } from "./ValidationResult";

export class TokenAndTemplateCreationValidator<
T extends {
expiresAt?: ISO8601DateTimeString;
passwordProtection?: {
password: string;
passwordIsPin?: true;
};
}
> extends SchemaValidator<T> {
public constructor(protected override readonly schema: JsonSchema) {
super(schema);
}

public override validate(input: T): ValidationResult {
const validationResult = super.validate(input);

if (input.expiresAt && CoreDate.from(input.expiresAt).isExpired()) {
validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPropertyValue(`'expiresAt' must be in the future`), "expiresAt"));
}

if (input.passwordProtection) {
const passwordProtection = input.passwordProtection;

if (passwordProtection.passwordIsPin) {
if (!/^[0-9]{4,16}$/.test(passwordProtection.password)) {
validationResult.addFailure(
new ValidationFailure(
RuntimeErrors.general.invalidPropertyValue(
`'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.`
),
"passwordProtection"
)
);
}
}
}

return validationResult;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { TokenMapper } from "../tokens/TokenMapper";

export interface LoadItemFromTruncatedReferenceRequest {
reference: TokenReferenceString | FileReferenceString | RelationshipTemplateReferenceString;
password?: string;
}

class Validator extends SchemaValidator<LoadItemFromTruncatedReferenceRequest> {
Expand Down Expand Up @@ -65,7 +66,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase<LoadItemFromT
const reference = request.reference;

if (reference.startsWith(Base64ForIdPrefix.RelationshipTemplate)) {
const template = await this.templateController.loadPeerRelationshipTemplateByTruncated(reference);
const template = await this.templateController.loadPeerRelationshipTemplateByTruncated(reference, request.password);
return Result.ok({
type: "RelationshipTemplate",
value: RelationshipTemplateMapper.toRelationshipTemplateDTO(template)
Expand All @@ -80,11 +81,11 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase<LoadItemFromT
});
}

return await this.handleTokenReference(reference);
return await this.handleTokenReference(reference, request.password);
}

private async handleTokenReference(tokenReference: string): Promise<Result<LoadItemFromTruncatedReferenceResponse>> {
const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true);
private async handleTokenReference(tokenReference: string, password?: string): Promise<Result<LoadItemFromTruncatedReferenceResponse>> {
const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true, password);

if (!token.cache) {
throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString());
Expand All @@ -93,7 +94,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase<LoadItemFromT
const tokenContent = token.cache.content;

if (tokenContent instanceof TokenContentRelationshipTemplate) {
const template = await this.templateController.loadPeerRelationshipTemplateByTokenContent(tokenContent);
const template = await this.templateController.loadPeerRelationshipTemplateByTokenContent(tokenContent, password);
return Result.ok({
type: "RelationshipTemplate",
value: RelationshipTemplateMapper.toRelationshipTemplateDTO(template)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { Result } from "@js-soft/ts-utils";
import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types";
import { AccountController, File, FileController, TokenContentFile, TokenController } from "@nmshd/transport";
import { AccountController, File, FileController, PasswordProtectionCreationParameters, TokenContentFile, TokenController } from "@nmshd/transport";
import { Inject } from "@nmshd/typescript-ioc";
import { TokenDTO } from "../../../types";
import { AddressString, FileIdString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase } from "../../common";
import { AddressString, FileIdString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common";
import { TokenMapper } from "../tokens/TokenMapper";

export interface CreateTokenForFileRequest {
fileId: FileIdString;
expiresAt?: ISO8601DateTimeString;
ephemeral?: boolean;
forIdentity?: AddressString;
passwordProtection?: {
/**
* @minLength 1
*/
password: string;
passwordIsPin?: true;
};
}

class Validator extends SchemaValidator<CreateTokenForFileRequest> {
class Validator extends TokenAndTemplateCreationValidator<CreateTokenForFileRequest> {
public constructor(@Inject schemaRepository: SchemaRepository) {
super(schemaRepository.getSchema("CreateTokenForFileRequest"));
}
Expand Down Expand Up @@ -48,7 +55,8 @@ export class CreateTokenForFileUseCase extends UseCase<CreateTokenForFileRequest
content: tokenContent,
expiresAt: tokenExpiry,
ephemeral,
forIdentity: request.forIdentity ? CoreAddress.from(request.forIdentity) : undefined
forIdentity: request.forIdentity ? CoreAddress.from(request.forIdentity) : undefined,
passwordProtection: PasswordProtectionCreationParameters.create(request.passwordProtection)
});

if (!ephemeral) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { Result } from "@js-soft/ts-utils";
import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types";
import { File, FileController, TokenContentFile, TokenController } from "@nmshd/transport";
import { File, FileController, PasswordProtectionCreationParameters, TokenContentFile, TokenController } from "@nmshd/transport";
import { Inject } from "@nmshd/typescript-ioc";
import { AddressString, FileIdString, ISO8601DateTimeString, QRCode, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase } from "../../common";
import { AddressString, FileIdString, ISO8601DateTimeString, QRCode, RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common";

export interface CreateTokenQRCodeForFileRequest {
fileId: FileIdString;
expiresAt?: ISO8601DateTimeString;
forIdentity?: AddressString;
passwordProtection?: {
/**
* @minLength 1
*/
password: string;
passwordIsPin?: true;
};
}

export interface CreateTokenQRCodeForFileResponse {
qrCodeBytes: string;
}

class Validator extends SchemaValidator<CreateTokenQRCodeForFileRequest> {
class Validator extends TokenAndTemplateCreationValidator<CreateTokenQRCodeForFileRequest> {
public constructor(@Inject schemaRepository: SchemaRepository) {
super(schemaRepository.getSchema("CreateTokenQRCodeForFileRequest"));
}
Expand Down Expand Up @@ -47,7 +54,8 @@ export class CreateTokenQRCodeForFileUseCase extends UseCase<CreateTokenQRCodeFo
content: tokenContent,
expiresAt: tokenExpiry,
ephemeral: true,
forIdentity: request.forIdentity ? CoreAddress.from(request.forIdentity) : undefined
forIdentity: request.forIdentity ? CoreAddress.from(request.forIdentity) : undefined,
passwordProtection: PasswordProtectionCreationParameters.create(request.passwordProtection)
});

const qrCode = await QRCode.forTruncateable(token);
Expand Down
Loading

0 comments on commit 2241e42

Please sign in to comment.