@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 +31 -0
- package/lib/db/UsersCollection.d.ts +3 -1
- package/lib/db/WebAuthnCredentialsCreationRequestsCollection.d.ts +1 -1
- package/lib/module/configureUsersModule.d.ts +6 -2
- package/lib/module/configureUsersModule.js +27 -5
- package/lib/module/configureUsersWebAuthnModule.d.ts +5 -4
- package/lib/module/configureUsersWebAuthnModule.js +4 -3
- package/lib/module/pbkdf2.js +2 -1
- package/package.json +8 -7
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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(
|
|
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:
|
|
781
|
+
deleted: null,
|
|
760
782
|
}));
|
|
761
783
|
return tags.filter(Boolean).toSorted();
|
|
762
784
|
},
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
96
|
+
_id: generateDbObjectId(),
|
|
96
97
|
challenge,
|
|
97
98
|
origin,
|
|
98
99
|
factor: 'either',
|
package/lib/module/pbkdf2.js
CHANGED
|
@@ -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
|
|
30
|
+
return timingSafeStringEqual(comparableHash, hash);
|
|
30
31
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unchainedshop/core-users",
|
|
3
|
-
"
|
|
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.
|
|
39
|
-
"@unchainedshop/file-upload": "^4.
|
|
40
|
-
"@unchainedshop/logger": "^4.
|
|
41
|
-
"@unchainedshop/mongodb": "^4.
|
|
42
|
-
"@unchainedshop/roles": "^4.
|
|
43
|
-
"@unchainedshop/utils": "^4.
|
|
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"
|