@unchainedshop/core-users 4.5.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,6 +120,37 @@ await usersModule.updateProfile(userId, {
120
120
  | `USER_UPDATE_ROLES` | Roles changed |
121
121
  | `USER_ACCOUNT_ACTION` | Account action triggered |
122
122
 
123
+ ## Security
124
+
125
+ This module implements security best practices for user authentication and data protection.
126
+
127
+ ### Password Security
128
+
129
+ - **Algorithm**: PBKDF2 with SHA-512
130
+ - **Iterations**: 300,000 (exceeds OWASP recommendation)
131
+ - **Salt**: 16 bytes, cryptographically random per password
132
+ - **Key Length**: 256 bytes
133
+ - **FIPS 140-3**: Compatible when running on FIPS-enabled Node.js
134
+
135
+ ### Token Security
136
+
137
+ - **Generation**: `crypto.randomUUID()` (CSPRNG-based)
138
+ - **Storage**: SHA-256 hashed before database storage
139
+ - **Expiration**: Time-limited (configurable, default 1 hour)
140
+ - **Single-use**: Tokens invalidated after verification
141
+
142
+ ### WebAuthn/FIDO2
143
+
144
+ Full support for passwordless authentication via hardware security keys and platform authenticators, providing phishing-resistant authentication.
145
+
146
+ ### Data Protection
147
+
148
+ - Sensitive data (password hashes, tokens) stripped from event emissions via `removeConfidentialServiceHashes()`
149
+ - Soft delete preserves audit trail while removing PII
150
+ - Email addresses and profile data access-controlled via RBAC
151
+
152
+ See [SECURITY.md](../../SECURITY.md) for complete security documentation.
153
+
123
154
  ## License
124
155
 
125
156
  EUPL-1.2
@@ -30,7 +30,7 @@ export interface Email {
30
30
  }
31
31
  export type User = {
32
32
  _id: string;
33
- deleted?: Date;
33
+ deleted?: Date | null;
34
34
  avatarId?: string;
35
35
  emails: Email[];
36
36
  guest: boolean;
@@ -55,6 +55,8 @@ export interface UserQuery {
55
55
  tags?: string[];
56
56
  userIds?: string[];
57
57
  username?: string;
58
+ usernames?: string[];
59
+ emails?: string[];
58
60
  web3Verified?: boolean;
59
61
  }
60
62
  export declare const UsersCollection: (db: mongodb.Db) => Promise<mongodb.Collection<User>>;
@@ -6,7 +6,7 @@ export interface WebAuthnCredentialsCreationRequest {
6
6
  factor: 'first' | 'second' | 'either';
7
7
  }
8
8
  type Collection = WebAuthnCredentialsCreationRequest & {
9
- _id: number;
9
+ _id: string;
10
10
  };
11
11
  export declare const WebAuthnCredentialsCreationRequestsCollection: (db: mongodb.Db) => Promise<mongodb.Collection<Collection>>;
12
12
  export {};
@@ -3,7 +3,7 @@ import { type User, type UserQuery, type Email, type UserLastLogin, type UserPro
3
3
  import { type SortOption } from '@unchainedshop/utils';
4
4
  import { type UserRegistrationData, type UserSettingsOptions } from '../users-settings.ts';
5
5
  export declare const removeConfidentialServiceHashes: (rawUser: User) => User;
6
- export declare const buildFindSelector: ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, web3Verified, }: UserQuery) => mongodb.Filter<User>;
6
+ export declare const buildFindSelector: ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, usernames, emails, web3Verified, }: UserQuery) => mongodb.Filter<User>;
7
7
  export declare const configureUsersModule: (moduleInput: ModuleInput<UserSettingsOptions>) => Promise<{
8
8
  webAuthn: {
9
9
  findMDSMetadataForAAGUID: (aaguid: string) => Promise<{
@@ -33,7 +33,7 @@ export declare const configureUsersModule: (moduleInput: ModuleInput<UserSetting
33
33
  counter?: number;
34
34
  transports?: import("@passwordless-id/webauthn/dist/esm/types.js").ExtendedAuthenticatorTransport[];
35
35
  }[], username: string, credentials: import("@passwordless-id/webauthn/dist/esm/types.js").AuthenticationJSON & {
36
- requestId: number;
36
+ requestId: string;
37
37
  }) => Promise<{
38
38
  userHandle: string;
39
39
  counter: number;
@@ -95,6 +95,10 @@ export declare const configureUsersModule: (moduleInput: ModuleInput<UserSetting
95
95
  created: Date;
96
96
  }): Promise<User | null>;
97
97
  removeWebAuthnCredential(userId: string, credentialsId: string): Promise<User | null>;
98
+ createAccessToken(username: string): Promise<{
99
+ user: User;
100
+ token: string;
101
+ } | null>;
98
102
  setAccessToken(username: string, plainSecret: string): Promise<User | null>;
99
103
  verifyWeb3SignatureAndUpdate(user: User, credentials: {
100
104
  address: string;
@@ -31,7 +31,7 @@ export const removeConfidentialServiceHashes = (rawUser) => {
31
31
  delete user?.services;
32
32
  return user;
33
33
  };
34
- export const buildFindSelector = ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, web3Verified, }) => {
34
+ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, usernames, emails, web3Verified, }) => {
35
35
  const selector = {};
36
36
  if (!includeDeleted)
37
37
  selector.deleted = null;
@@ -43,6 +43,16 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
43
43
  if (username) {
44
44
  selector.username = insensitiveTrimmedRegexOperator(username);
45
45
  }
46
+ if (usernames?.length) {
47
+ selector.username = {
48
+ $in: usernames.map((u) => insensitiveTrimmedRegexOperator(u)),
49
+ };
50
+ }
51
+ if (emails?.length) {
52
+ selector['emails.address'] = {
53
+ $in: emails.map((e) => insensitiveTrimmedRegexOperator(e)),
54
+ };
55
+ }
46
56
  if (emailVerified === true) {
47
57
  selector['emails.verified'] = true;
48
58
  }
@@ -189,7 +199,7 @@ export const configureUsersModule = async (moduleInput) => {
189
199
  }).toArray();
190
200
  },
191
201
  async userExists({ userId }) {
192
- const userCount = await Users.countDocuments({ _id: userId, deleted: { $exists: false } }, { limit: 1 });
202
+ const userCount = await Users.countDocuments({ _id: userId, deleted: null }, { limit: 1 });
193
203
  return userCount === 1;
194
204
  },
195
205
  primaryEmail(user) {
@@ -311,7 +321,7 @@ export const configureUsersModule = async (moduleInput) => {
311
321
  const existingEntry = user.services?.web3?.find((service) => service.address.toLowerCase() === address.toLowerCase());
312
322
  if (existingEntry)
313
323
  return user;
314
- const nonce = Math.floor(Math.random() * 1000000).toString();
324
+ const nonce = crypto.randomUUID();
315
325
  const updatedUser = await Users.findOneAndUpdate(generateDbFilterById(userId), {
316
326
  $push: {
317
327
  'services.web3': {
@@ -369,8 +379,20 @@ export const configureUsersModule = async (moduleInput) => {
369
379
  }, { returnDocument: 'after' });
370
380
  return updatedUser;
371
381
  },
382
+ async createAccessToken(username) {
383
+ const plainToken = crypto.randomUUID();
384
+ const secret = await sha256(plainToken);
385
+ const updatedUser = await Users.findOneAndUpdate({ username: insensitiveTrimmedRegexOperator(username) }, {
386
+ $set: {
387
+ 'services.token': { secret },
388
+ },
389
+ }, { returnDocument: 'after' });
390
+ if (!updatedUser)
391
+ return null;
392
+ return { user: updatedUser, token: plainToken };
393
+ },
372
394
  async setAccessToken(username, plainSecret) {
373
- const secret = await sha256(`${username}:${plainSecret}`);
395
+ const secret = await sha256(plainSecret);
374
396
  const updatedUser = await Users.findOneAndUpdate({ username: insensitiveTrimmedRegexOperator(username) }, {
375
397
  $set: {
376
398
  'services.token': { secret },
@@ -756,7 +778,7 @@ export const configureUsersModule = async (moduleInput) => {
756
778
  existingTags: async () => {
757
779
  const tags = (await Users.distinct('tags', {
758
780
  tags: { $exists: true },
759
- deleted: { $exists: false },
781
+ deleted: null,
760
782
  }));
761
783
  return tags.filter(Boolean).toSorted();
762
784
  },
@@ -1,11 +1,11 @@
1
- import type { ModuleInput } from '@unchainedshop/mongodb';
1
+ import { type ModuleInput } from '@unchainedshop/mongodb';
2
2
  import type { RegistrationJSON, AuthenticationJSON, CredentialInfo, NamedAlgo, ExtendedAuthenticatorTransport } from '@passwordless-id/webauthn/dist/esm/types.js';
3
3
  export type { RegistrationJSON, AuthenticationJSON, CredentialInfo, NamedAlgo, ExtendedAuthenticatorTransport, };
4
4
  export declare function toArrayBuffer(buffer: Buffer): ArrayBuffer;
5
5
  export declare function buf2hex(buffer: ArrayBuffer): string;
6
6
  export interface WebAuthnCredentialCreationOptions {
7
7
  challenge: string;
8
- requestId: number;
8
+ requestId: string;
9
9
  rp: {
10
10
  id: string;
11
11
  name: string;
@@ -29,7 +29,7 @@ export interface WebAuthnCredentialCreationOptions {
29
29
  }
30
30
  export interface WebAuthnCredentialRequestOptions {
31
31
  challenge: string;
32
- requestId: number;
32
+ requestId: string;
33
33
  rpId: string;
34
34
  timeout: number;
35
35
  userVerification?: 'required' | 'preferred' | 'discouraged';
@@ -75,10 +75,11 @@ export declare const configureUsersWebAuthnModule: ({ db }: ModuleInput<Record<s
75
75
  counter?: number;
76
76
  transports?: ExtendedAuthenticatorTransport[];
77
77
  }[], username: string, credentials: AuthenticationJSON & {
78
- requestId: number;
78
+ requestId: string;
79
79
  }) => Promise<{
80
80
  userHandle: string;
81
81
  counter: number;
82
82
  } | null>;
83
83
  deleteUserWebAuthnCredentials: (username: string) => Promise<number>;
84
84
  }>;
85
+ export type UsersWebAuthnModule = Awaited<ReturnType<typeof configureUsersWebAuthnModule>>;
@@ -1,3 +1,4 @@
1
+ import { generateDbObjectId } from '@unchainedshop/mongodb';
1
2
  import { createLogger } from '@unchainedshop/logger';
2
3
  import pMemoize from 'p-memoize';
3
4
  import ExpiryMap from 'expiry-map';
@@ -5,7 +6,6 @@ import { server as webauthnServer } from '@passwordless-id/webauthn';
5
6
  import { WebAuthnCredentialsCreationRequestsCollection } from "../db/WebAuthnCredentialsCreationRequestsCollection.js";
6
7
  const logger = createLogger('unchained:core-users');
7
8
  const ONE_DAY_MS = 24 * 60 * 60 * 1000;
8
- const { ROOT_URL = 'http://localhost:4010', EMAIL_WEBSITE_NAME = 'Unchained' } = process.env;
9
9
  async function fetchMDSEntriesImpl() {
10
10
  const cache = new Map();
11
11
  try {
@@ -48,6 +48,7 @@ export function buf2hex(buffer) {
48
48
  .join('');
49
49
  }
50
50
  export const configureUsersWebAuthnModule = async ({ db }) => {
51
+ const { ROOT_URL = 'http://localhost:4010', EMAIL_WEBSITE_NAME = 'Unchained' } = process.env;
51
52
  const WebAuthnCredentialsCreationRequests = await WebAuthnCredentialsCreationRequestsCollection(db);
52
53
  const thisDomain = new URL(ROOT_URL).hostname;
53
54
  const thisOrigin = new URL(ROOT_URL).origin;
@@ -60,7 +61,7 @@ export const configureUsersWebAuthnModule = async ({ db }) => {
60
61
  createCredentialCreationOptions: async (origin, username, extensionOptions) => {
61
62
  const challenge = webauthnServer.randomChallenge();
62
63
  const { insertedId } = await WebAuthnCredentialsCreationRequests.insertOne({
63
- _id: new Date().getTime(),
64
+ _id: generateDbObjectId(),
64
65
  challenge,
65
66
  origin,
66
67
  factor: 'either',
@@ -92,7 +93,7 @@ export const configureUsersWebAuthnModule = async ({ db }) => {
92
93
  createCredentialRequestOptions: async (origin, username, extensionOptions) => {
93
94
  const challenge = webauthnServer.randomChallenge();
94
95
  const { insertedId } = await WebAuthnCredentialsCreationRequests.insertOne({
95
- _id: new Date().getTime(),
96
+ _id: generateDbObjectId(),
96
97
  challenge,
97
98
  origin,
98
99
  factor: 'either',
@@ -1,3 +1,4 @@
1
+ import { timingSafeStringEqual } from '@unchainedshop/utils';
1
2
  const PBKDF2_ITERATIONS = 300000;
2
3
  const PBKDF2_KEY_LENGTH = 256;
3
4
  const PBKDF2_SALT_LENGTH = 16;
@@ -26,5 +27,5 @@ export async function getDerivedKey(salt, password, iterations = PBKDF2_ITERATIO
26
27
  }
27
28
  export async function compare(password, hash, salt) {
28
29
  const comparableHash = await getDerivedKey(salt, password);
29
- return comparableHash === hash;
30
+ return timingSafeStringEqual(comparableHash, hash);
30
31
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@unchainedshop/core-users",
3
- "version": "4.5.0",
3
+ "description": "User management module for the Unchained Engine with authentication support",
4
+ "version": "4.6.1",
4
5
  "main": "lib/users-index.js",
5
6
  "types": "lib/users-index.d.ts",
6
7
  "type": "module",
@@ -35,12 +36,12 @@
35
36
  "homepage": "https://github.com/unchainedshop/unchained#readme",
36
37
  "dependencies": {
37
38
  "@passwordless-id/webauthn": "^2.3.1",
38
- "@unchainedshop/events": "^4.5.0",
39
- "@unchainedshop/file-upload": "^4.5.0",
40
- "@unchainedshop/logger": "^4.5.0",
41
- "@unchainedshop/mongodb": "^4.5.0",
42
- "@unchainedshop/roles": "^4.5.0",
43
- "@unchainedshop/utils": "^4.5.0",
39
+ "@unchainedshop/events": "^4.6.0",
40
+ "@unchainedshop/file-upload": "^4.6.0",
41
+ "@unchainedshop/logger": "^4.6.0",
42
+ "@unchainedshop/mongodb": "^4.6.0",
43
+ "@unchainedshop/roles": "^4.6.0",
44
+ "@unchainedshop/utils": "^4.6.0",
44
45
  "bcryptjs": "^3.0.2",
45
46
  "expiry-map": "^2.0.0",
46
47
  "p-memoize": "^8.0.0"