@proteinjs/user-server 1.22.2 → 1.22.4
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/CHANGELOG.md +26 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
- package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
- package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
- package/dist/src/authentication/authenticate.js +2 -2
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +3 -2
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +29 -14
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +7 -6
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +2 -2
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/signup.d.ts.map +1 -1
- package/dist/src/routes/signup.js +5 -2
- package/dist/src/routes/signup.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +5 -1
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/src/services/MachineCredentials.d.ts +10 -1
- package/dist/src/services/MachineCredentials.d.ts.map +1 -1
- package/dist/src/services/MachineCredentials.js +46 -15
- package/dist/src/services/MachineCredentials.js.map +1 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +6 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/Signup.d.ts +15 -0
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +79 -27
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/test/DevLogin.test.js +68 -0
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/LogCapture.d.ts +10 -0
- package/dist/test/LogCapture.d.ts.map +1 -1
- package/dist/test/LogCapture.js +19 -0
- package/dist/test/LogCapture.js.map +1 -1
- package/dist/test/LogCapture.test.d.ts +2 -0
- package/dist/test/LogCapture.test.d.ts.map +1 -0
- package/dist/test/LogCapture.test.js +86 -0
- package/dist/test/LogCapture.test.js.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +156 -28
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
- package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
- package/dist/test/NoAddressesInLogs.integration.test.js +719 -0
- package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
- package/dist/test/SignupRace.integration.test.d.ts +2 -0
- package/dist/test/SignupRace.integration.test.d.ts.map +1 -0
- package/dist/test/SignupRace.integration.test.js +320 -0
- package/dist/test/SignupRace.integration.test.js.map +1 -0
- package/dist/test/SignupRoute.test.js +80 -1
- package/dist/test/SignupRoute.test.js.map +1 -1
- package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
- package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
- package/dist/test/SocketIOSessionWatcher.test.js +144 -0
- package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/package.json +6 -5
- package/src/authentication/SocketIOSessionWatcher.ts +13 -1
- package/src/authentication/UserStatusTableWatcher.ts +2 -1
- package/src/authentication/authenticate.ts +1 -1
- package/src/authorization/userCache.ts +3 -2
- package/src/routes/devLogin.ts +22 -10
- package/src/routes/executePasswordReset.ts +6 -5
- package/src/routes/initiatePasswordReset.ts +1 -1
- package/src/routes/signup.ts +5 -2
- package/src/routes/validateResetPasswordToken.ts +5 -1
- package/src/services/MachineCredentials.ts +44 -9
- package/src/services/Roles.ts +6 -2
- package/src/services/Signup.ts +60 -15
- package/test/DevLogin.test.ts +35 -1
- package/test/LogCapture.test.ts +29 -0
- package/test/LogCapture.ts +16 -0
- package/test/MachineAccounts.integration.test.ts +97 -22
- package/test/NoAddressesInLogs.integration.test.ts +410 -0
- package/test/SignupRace.integration.test.ts +230 -0
- package/test/SignupRoute.test.ts +54 -1
- package/test/SocketIOSessionWatcher.test.ts +86 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/user-server",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.4",
|
|
4
4
|
"description": "User server components",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -22,15 +22,16 @@
|
|
|
22
22
|
"test": "jest --runInBand --passWithNoTests"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@proteinjs/db": "^1.47.
|
|
25
|
+
"@proteinjs/db": "^1.47.3",
|
|
26
26
|
"@proteinjs/db-file": "^1.5.3",
|
|
27
27
|
"@proteinjs/email-server": "^1.4.0",
|
|
28
28
|
"@proteinjs/logger": "^1.0.21",
|
|
29
29
|
"@proteinjs/reflection": "^1.2.0",
|
|
30
|
-
"@proteinjs/server": "^3.5
|
|
30
|
+
"@proteinjs/server": "^3.8.5",
|
|
31
31
|
"@proteinjs/server-api": "^3.0.11",
|
|
32
|
-
"@proteinjs/user": "^1.20.
|
|
32
|
+
"@proteinjs/user": "^1.20.2",
|
|
33
33
|
"@proteinjs/util": "^1.6.1",
|
|
34
|
+
"@proteinjs/util-node": "^1.11.1",
|
|
34
35
|
"argon2": "^0.45.1",
|
|
35
36
|
"crypto-js": "4.0.0",
|
|
36
37
|
"express-session": "1.17.1",
|
|
@@ -59,5 +60,5 @@
|
|
|
59
60
|
},
|
|
60
61
|
"main": "./dist/generated/index.js",
|
|
61
62
|
"types": "./dist/generated/index.d.ts",
|
|
62
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "a2e6c0a0688d7681fb398354a07c76a9bb08644e"
|
|
63
64
|
}
|
|
@@ -5,6 +5,11 @@ import { SocketIOServerRepo } from '@proteinjs/server';
|
|
|
5
5
|
/**
|
|
6
6
|
* Handles Socket.IO session cleanup (such as disconnecting sockets)
|
|
7
7
|
* when sessions are deleted.
|
|
8
|
+
*
|
|
9
|
+
* A deletion is not always made by a running server: a boot's source-record sync — and a
|
|
10
|
+
* migration Job, which never starts one — deactivates a withdrawn machine account and deletes its
|
|
11
|
+
* sessions before any socket server exists. Then no socket can be open, and there is nothing to
|
|
12
|
+
* close.
|
|
8
13
|
*/
|
|
9
14
|
export class SocketIOSessionWatcher implements TableWatcher<Session> {
|
|
10
15
|
name(): string {
|
|
@@ -21,7 +26,14 @@ export class SocketIOSessionWatcher implements TableWatcher<Session> {
|
|
|
21
26
|
initialQb: QueryBuilder<T>,
|
|
22
27
|
deleteQb: QueryBuilder<T>
|
|
23
28
|
): Promise<void> {
|
|
29
|
+
const socketIOServer = SocketIOServerRepo.getSocketIOServerIfExists();
|
|
24
30
|
const deletedSessionIds = deletedRecords.map((deletedRecord) => deletedRecord.sessionId);
|
|
25
|
-
|
|
31
|
+
// No rooms is never sent: socket.io applies an operation with an empty room list to EVERY
|
|
32
|
+
// socket on the namespace.
|
|
33
|
+
if (!socketIOServer || deletedSessionIds.length === 0) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
socketIOServer.in(deletedSessionIds).disconnectSockets(true);
|
|
26
38
|
}
|
|
27
39
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { QueryBuilder, Table, TableWatcher, getDbAsSystem } from '@proteinjs/db';
|
|
2
2
|
import { User, tables } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The one owner of "deactivated ⇒ sessions die": any user-table update writing
|
|
@@ -48,7 +49,7 @@ export class UserStatusTableWatcher implements TableWatcher<User> {
|
|
|
48
49
|
if (deletedSessions > 0) {
|
|
49
50
|
this.logger.info({
|
|
50
51
|
message: `Deactivation killed sessions`,
|
|
51
|
-
obj: {
|
|
52
|
+
obj: { account: new RequestDigests().account(user.email), sessions: deletedSessions },
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
2
|
import { User, tables } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
-
import { RequestDigests } from '
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
5
5
|
import { DefaultAdminCredentials } from './DefaultAdminCredentials';
|
|
6
6
|
import { PasswordHasher } from './PasswordHasher';
|
|
7
7
|
|
|
@@ -2,6 +2,7 @@ import moment from 'moment';
|
|
|
2
2
|
import { SessionDataCache } from '@proteinjs/server-api';
|
|
3
3
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
4
4
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
5
6
|
import { User, tables, guestUser, USER_SESSION_CACHE_KEY } from '@proteinjs/user';
|
|
6
7
|
import { DefaultAdminCredentials } from '../authentication/DefaultAdminCredentials';
|
|
7
8
|
|
|
@@ -42,7 +43,7 @@ export const userCache: SessionDataCache<User> = {
|
|
|
42
43
|
// account is deactivated — every request runs as the unauthenticated guest.
|
|
43
44
|
logger.warn({
|
|
44
45
|
message: `Session references a deactivated account; resolving as unauthenticated`,
|
|
45
|
-
obj: { sessionId, userEmail },
|
|
46
|
+
obj: { sessionId, account: new RequestDigests().account(userEmail) },
|
|
46
47
|
});
|
|
47
48
|
} else if (accountUser) {
|
|
48
49
|
delete (accountUser as any)['password'];
|
|
@@ -54,7 +55,7 @@ export const userCache: SessionDataCache<User> = {
|
|
|
54
55
|
// build as an unhandled rejection and downs the process.
|
|
55
56
|
logger.warn({
|
|
56
57
|
message: `Session references an account that does not exist; resolving as unauthenticated`,
|
|
57
|
-
obj: { sessionId, userEmail },
|
|
58
|
+
obj: { sessionId, account: new RequestDigests().account(userEmail) },
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/routes/devLogin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
3
4
|
import { emailRegex } from '@proteinjs/util';
|
|
4
5
|
import { establishSession } from '../authentication/establishSession';
|
|
5
6
|
import { Roles } from '../services/Roles';
|
|
@@ -88,22 +89,33 @@ export const devLogin: Route = {
|
|
|
88
89
|
email = requested;
|
|
89
90
|
}
|
|
90
91
|
email = email.toLowerCase();
|
|
92
|
+
const digests = new RequestDigests();
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
let creation: 'created' | 'exists';
|
|
95
|
+
try {
|
|
96
|
+
creation = await new Signup().createAccount({
|
|
97
|
+
name: email.slice(0, email.indexOf('@')),
|
|
98
|
+
email,
|
|
99
|
+
password: 'test',
|
|
100
|
+
emailVerified: false, // same shape an inviteless signup produces
|
|
101
|
+
invitedBy: null,
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// createAccount refuses in plain words (an address a machine-account declaration owns); the
|
|
105
|
+
// dev door answers the way its other refusals do — the words, no session, no row.
|
|
106
|
+
response
|
|
107
|
+
.status(400)
|
|
108
|
+
.send(`/dev/login: ${error instanceof Error ? error.message : 'the account could not be created'}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
99
111
|
if (creation === 'created') {
|
|
100
|
-
logger.info({ message: 'Dev auto-login created missing test account', obj: { email } });
|
|
112
|
+
logger.info({ message: 'Dev auto-login created missing test account', obj: { account: digests.account(email) } });
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
const bootstrapEmail = (process.env.DEV_BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase();
|
|
104
116
|
if (bootstrapEmail && email === bootstrapEmail) {
|
|
105
117
|
const outcome = await new Roles().bootstrapAdmin(email);
|
|
106
|
-
logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { email } });
|
|
118
|
+
logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { account: digests.account(email) } });
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
const bootstrapRoles = DevBootstrapRoles.rolesFor(email);
|
|
@@ -115,7 +127,7 @@ export const devLogin: Route = {
|
|
|
115
127
|
// establishSession commits the session row before the redirect — the redirected GET / must
|
|
116
128
|
// never read the store ahead of the write (observed: first /dev/login load landed on /login).
|
|
117
129
|
await establishSession(request, email);
|
|
118
|
-
logger.info({ message: 'Dev auto-login session established', obj: { email } });
|
|
130
|
+
logger.info({ message: 'Dev auto-login session established', obj: { account: digests.account(email) } });
|
|
119
131
|
response.redirect('/');
|
|
120
132
|
},
|
|
121
133
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { routes } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
5
6
|
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
6
|
-
import { RequestDigests } from '../throttle/RequestDigests';
|
|
7
7
|
import { signInThrottle } from '../throttle/SignInThrottle';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -27,6 +27,7 @@ export const executePasswordReset: Route = {
|
|
|
27
27
|
method: routes.executePasswordReset.method,
|
|
28
28
|
onRequest: async (request, response): Promise<void> => {
|
|
29
29
|
const logger = new Logger({ name: 'executePasswordReset' });
|
|
30
|
+
const digests = new RequestDigests();
|
|
30
31
|
const { token, newPassword } = request.body ?? {};
|
|
31
32
|
if (typeof newPassword !== 'string' || newPassword.length === 0) {
|
|
32
33
|
response.status(400).send({ error: 'New password cannot be blank' });
|
|
@@ -45,7 +46,7 @@ export const executePasswordReset: Route = {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (resolution.status === 'expired') {
|
|
48
|
-
logger.info({ message: `Expired reset token used`, obj: {
|
|
49
|
+
logger.info({ message: `Expired reset token used`, obj: { account: digests.account(resolution.user.email) } });
|
|
49
50
|
response.status(400).send({ error: 'Reset token has expired' });
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
@@ -53,13 +54,13 @@ export const executePasswordReset: Route = {
|
|
|
53
54
|
const { user } = resolution;
|
|
54
55
|
const hashedPassword = await new PasswordHasher().hash(newPassword);
|
|
55
56
|
if (!(await resetToken.redeem(user, resolution.token, hashedPassword))) {
|
|
56
|
-
logger.info({ message: `Reset token already redeemed`, obj: {
|
|
57
|
+
logger.info({ message: `Reset token already redeemed`, obj: { account: digests.account(user.email) } });
|
|
57
58
|
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
58
59
|
return;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
signInThrottle.recordSuccess(
|
|
62
|
-
logger.info({ message: `Password successfully reset`, obj: {
|
|
62
|
+
signInThrottle.recordSuccess(digests.account(user.email));
|
|
63
|
+
logger.info({ message: `Password successfully reset`, obj: { account: digests.account(user.email) } });
|
|
63
64
|
response.send({ message: 'Password has been successfully reset' });
|
|
64
65
|
},
|
|
65
66
|
};
|
|
@@ -2,6 +2,7 @@ import { Route } from '@proteinjs/server-api';
|
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import { routes, tables, uiRoutes } from '@proteinjs/user';
|
|
4
4
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
5
6
|
import moment from 'moment';
|
|
6
7
|
import {
|
|
7
8
|
EmailSender,
|
|
@@ -9,7 +10,6 @@ import {
|
|
|
9
10
|
} from '@proteinjs/email-server';
|
|
10
11
|
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
11
12
|
import { ClientAddress } from '../throttle/ClientAddress';
|
|
12
|
-
import { RequestDigests } from '../throttle/RequestDigests';
|
|
13
13
|
import { passwordResetThrottle } from '../throttle/PasswordResetThrottle';
|
|
14
14
|
|
|
15
15
|
/**
|
package/src/routes/signup.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
3
4
|
import { UserSignup, routes } from '@proteinjs/user';
|
|
4
5
|
import { establishSession } from '../authentication/establishSession';
|
|
5
6
|
import { Signup } from '../services/Signup';
|
|
@@ -40,8 +41,10 @@ export const signup: Route = {
|
|
|
40
41
|
);
|
|
41
42
|
} catch (error: any) {
|
|
42
43
|
// createUser throws plain-words errors deliberately (invite expired / invite required /
|
|
43
|
-
// email missing); the message is the user-facing contract, same as the RPC layer's.
|
|
44
|
-
|
|
44
|
+
// email missing); the message is the user-facing contract, same as the RPC layer's. The log
|
|
45
|
+
// gets the error through the digest door: a concurrent double sign-up's unique-index refusal
|
|
46
|
+
// names the address in the database's own words.
|
|
47
|
+
logger.error({ message: 'Signup failed', error: new RequestDigests().redactError(error) });
|
|
45
48
|
response.send({ error: error instanceof Error ? error.message : 'Sign up failed.' });
|
|
46
49
|
return;
|
|
47
50
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { routes } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
5
6
|
|
|
6
7
|
export const validateResetPasswordToken: Route = {
|
|
@@ -26,7 +27,10 @@ export const validateResetPasswordToken: Route = {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
if (resolution.status === 'expired') {
|
|
29
|
-
logger.info({
|
|
30
|
+
logger.info({
|
|
31
|
+
message: `Expired reset token used`,
|
|
32
|
+
obj: { account: new RequestDigests().account(resolution.user.email) },
|
|
33
|
+
});
|
|
30
34
|
response.status(200).send({ isValid: false, message: 'Token has expired' });
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { randomBytes } from 'crypto';
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import {
|
|
4
|
+
MachineAccountRefusal,
|
|
4
5
|
MachineAccountView,
|
|
5
6
|
MachineCredentialsService,
|
|
6
7
|
MintedMachineCredential,
|
|
7
8
|
USER_PERMISSIONS,
|
|
9
|
+
User,
|
|
8
10
|
getMachineAccounts,
|
|
9
11
|
tables,
|
|
10
12
|
} from '@proteinjs/user';
|
|
11
13
|
import { Logger } from '@proteinjs/logger';
|
|
14
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
12
15
|
import { Service } from '@proteinjs/service';
|
|
13
16
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
14
17
|
|
|
@@ -23,7 +26,9 @@ import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
|
23
26
|
* account's existing sessions (the old
|
|
24
27
|
* credential dies with them), and returns the plaintext ONCE for pasting into the declaration's
|
|
25
28
|
* Secret Manager secret. The same call rotates. Machine rows only (`is_loaded_from_source`);
|
|
26
|
-
* human credentials go through the password-reset flow.
|
|
29
|
+
* human credentials go through the password-reset flow. A declared address held by a row the
|
|
30
|
+
* boot sync does not own (a person's signup, a hand-made row) has no machine account at all —
|
|
31
|
+
* the sync refused the declaration — and both the list and the mint say so.
|
|
27
32
|
*/
|
|
28
33
|
export class MachineCredentials implements MachineCredentialsService {
|
|
29
34
|
public serviceMetadata: Service['serviceMetadata'] = {
|
|
@@ -36,15 +41,22 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
36
41
|
const db = getDbAsSystem();
|
|
37
42
|
const views: MachineAccountView[] = [];
|
|
38
43
|
for (const declaration of getMachineAccounts()) {
|
|
39
|
-
const row = await db.get(tables.User, { email: declaration.email });
|
|
40
|
-
const
|
|
44
|
+
const row: User | undefined = await db.get(tables.User, { email: declaration.email });
|
|
45
|
+
const refusal = row && this.refusal(row);
|
|
41
46
|
views.push({
|
|
42
47
|
email: declaration.email,
|
|
43
48
|
accountName: declaration.accountName,
|
|
44
49
|
roles: [...declaration.roles],
|
|
45
50
|
secretName: declaration.secretName,
|
|
46
|
-
status:
|
|
47
|
-
|
|
51
|
+
status: !row
|
|
52
|
+
? 'pending first boot'
|
|
53
|
+
: refusal
|
|
54
|
+
? 'declaration refused'
|
|
55
|
+
: row.status === 'deactivated'
|
|
56
|
+
? 'deactivated'
|
|
57
|
+
: 'active',
|
|
58
|
+
...(refusal ? { refusal } : {}),
|
|
59
|
+
hasCredential: !!row && !refusal && !!row.password,
|
|
48
60
|
});
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -63,11 +75,20 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
const db = getDbAsSystem();
|
|
66
|
-
const user = await db.get(tables.User, { email: normalizedEmail });
|
|
67
|
-
if (!user
|
|
78
|
+
const user: User | undefined = await db.get(tables.User, { email: normalizedEmail });
|
|
79
|
+
if (!user) {
|
|
68
80
|
throw new Error(
|
|
69
81
|
`The machine account row for '${normalizedEmail}' has not been loaded from source yet — ` +
|
|
70
|
-
`the boot sync creates
|
|
82
|
+
`the boot sync creates it. Boot the server with the declaration, then mint.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const refusal = this.refusal(user);
|
|
87
|
+
if (refusal) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`The declaration for '${normalizedEmail}' was refused at boot: ${refusal} — the boot sync ` +
|
|
90
|
+
`never takes over a row it does not own, so there is no machine account to mint for. The ` +
|
|
91
|
+
`first boot after that row is gone creates it.`
|
|
71
92
|
);
|
|
72
93
|
}
|
|
73
94
|
|
|
@@ -79,7 +100,7 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
79
100
|
await db.delete(tables.Session, { userEmail: normalizedEmail });
|
|
80
101
|
logger.info({
|
|
81
102
|
message: `Minted machine credential`,
|
|
82
|
-
obj: {
|
|
103
|
+
obj: { account: new RequestDigests().account(normalizedEmail), secretName: declaration.secretName },
|
|
83
104
|
});
|
|
84
105
|
|
|
85
106
|
return {
|
|
@@ -91,4 +112,18 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
91
112
|
`secret, then restart the service that reads it — the previous credential is already invalid.`,
|
|
92
113
|
};
|
|
93
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Why the boot sync refused a declaration whose address this row holds — undefined for the
|
|
118
|
+
* account's own row. The sync owns a row only when it created it (`isLoadedFromSource`); any
|
|
119
|
+
* other row under a declared address is never taken over (@proteinjs/db's natural-key refusal),
|
|
120
|
+
* and the one machine marker says whose row it is.
|
|
121
|
+
*/
|
|
122
|
+
private refusal(row: User): MachineAccountRefusal | undefined {
|
|
123
|
+
if (row.isLoadedFromSource === true) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return row.machine === true ? 'a hand-made machine row holds this address' : 'a person holds this address';
|
|
128
|
+
}
|
|
94
129
|
}
|
package/src/services/Roles.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDbAsSystem, QueryBuilderFactory } from '@proteinjs/db';
|
|
2
2
|
import { RolesService, RolesCatalog, UserAuth, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { Service } from '@proteinjs/service';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -67,7 +68,10 @@ export class Roles implements RolesService {
|
|
|
67
68
|
await db.update(tables.User, { id: user.id, roles: [...roles, 'admin'] });
|
|
68
69
|
await db.insert(tables.RoleGrantEvent, { actor: user.id, target: user.id, role: 'admin', action: 'grant' });
|
|
69
70
|
});
|
|
70
|
-
logger.info({
|
|
71
|
+
logger.info({
|
|
72
|
+
message: 'Break-glass admin granted by the dev first-admin door',
|
|
73
|
+
obj: { target: user.id, account: new RequestDigests().account(email) },
|
|
74
|
+
});
|
|
71
75
|
return 'granted';
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -122,7 +126,7 @@ export class Roles implements RolesService {
|
|
|
122
126
|
});
|
|
123
127
|
logger.info({
|
|
124
128
|
message: 'Roles granted by the dev role-bootstrap door',
|
|
125
|
-
obj: { target: user.id, email, granted: outcome.granted },
|
|
129
|
+
obj: { target: user.id, account: new RequestDigests().account(email), granted: outcome.granted },
|
|
126
130
|
});
|
|
127
131
|
return outcome;
|
|
128
132
|
}
|
package/src/services/Signup.ts
CHANGED
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
User,
|
|
12
12
|
UserSignup,
|
|
13
13
|
USER_PERMISSIONS,
|
|
14
|
+
getMachineAccounts,
|
|
14
15
|
} from '@proteinjs/user';
|
|
15
16
|
import moment from 'moment';
|
|
16
17
|
import { lib } from 'crypto-js';
|
|
17
18
|
import { Logger } from '@proteinjs/logger';
|
|
19
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
18
20
|
import {
|
|
19
21
|
EmailSender,
|
|
20
22
|
getDefaultInviteEmailConfigFactory,
|
|
@@ -124,7 +126,10 @@ export class Signup implements SignupService {
|
|
|
124
126
|
invitedBy: invite ? invite.invitedBy : null,
|
|
125
127
|
});
|
|
126
128
|
if (creation === 'exists') {
|
|
127
|
-
logger.error({
|
|
129
|
+
logger.error({
|
|
130
|
+
message: `User with this email already exists`,
|
|
131
|
+
obj: { account: new RequestDigests().account(email) },
|
|
132
|
+
});
|
|
128
133
|
if (config.getExistingUserEmailContent) {
|
|
129
134
|
const { text, html } = config.getExistingUserEmailContent();
|
|
130
135
|
await emailSender.sendEmail({
|
|
@@ -146,7 +151,7 @@ export class Signup implements SignupService {
|
|
|
146
151
|
html,
|
|
147
152
|
...config.options,
|
|
148
153
|
});
|
|
149
|
-
logger.info({ message: `Created user`, obj: { email } });
|
|
154
|
+
logger.info({ message: `Created user`, obj: { account: new RequestDigests().account(email) } });
|
|
150
155
|
return { outcome: 'created', email };
|
|
151
156
|
}
|
|
152
157
|
|
|
@@ -183,7 +188,14 @@ export class Signup implements SignupService {
|
|
|
183
188
|
await this.emailInvite(caseInsensitiveEmail, token, config);
|
|
184
189
|
return { sent: true };
|
|
185
190
|
} catch (error: any) {
|
|
186
|
-
|
|
191
|
+
// The try block covers the Invite row's write and the mail: a refusal's own words may name
|
|
192
|
+
// the address, so the error reaches the log through the digest door too.
|
|
193
|
+
const digests = new RequestDigests();
|
|
194
|
+
logger.error({
|
|
195
|
+
message: 'Error sending invite',
|
|
196
|
+
obj: { invitee: digests.address(caseInsensitiveEmail) },
|
|
197
|
+
error: digests.redactError(error),
|
|
198
|
+
});
|
|
187
199
|
return {
|
|
188
200
|
sent: false,
|
|
189
201
|
error: 'Error occurred.',
|
|
@@ -218,7 +230,12 @@ export class Signup implements SignupService {
|
|
|
218
230
|
await this.emailInvite(caseInsensitiveEmail, token, config);
|
|
219
231
|
return { sent: true };
|
|
220
232
|
} catch (error: any) {
|
|
221
|
-
|
|
233
|
+
const digests = new RequestDigests();
|
|
234
|
+
logger.error({
|
|
235
|
+
message: 'Error re-sending invite',
|
|
236
|
+
obj: { invitee: digests.address(caseInsensitiveEmail) },
|
|
237
|
+
error: digests.redactError(error),
|
|
238
|
+
});
|
|
222
239
|
return {
|
|
223
240
|
sent: false,
|
|
224
241
|
error: 'Error occurred.',
|
|
@@ -300,6 +317,19 @@ export class Signup implements SignupService {
|
|
|
300
317
|
* (`devLogin`, which auto-creates missing same-domain test accounts). Not exposed as an RPC:
|
|
301
318
|
* the service surface is the `SignupService` INTERFACE (ServiceRouter walks its declared
|
|
302
319
|
* methods), so extra class methods stay server-internal.
|
|
320
|
+
*
|
|
321
|
+
* An address a `MachineAccount` declaration owns is never registered, whatever its row state:
|
|
322
|
+
* a person's row under it would make the boot sync refuse the declaration (it never takes over
|
|
323
|
+
* a row it does not own), so the address is refused here in plain words — the declared machine
|
|
324
|
+
* accounts of this build are the list, nothing else.
|
|
325
|
+
*
|
|
326
|
+
* 'exists' means the address has an account this call did not create — found by the check, or
|
|
327
|
+
* written by a concurrent create between the check and this insert. Two creates for one address
|
|
328
|
+
* both pass the check; the database's unique index on the address refuses the second insert, and
|
|
329
|
+
* that refusal is the same fact the check reads, so the second answers 'exists' exactly as if it
|
|
330
|
+
* had arrived a moment later: its caller gets the existing-address response, no session, and the
|
|
331
|
+
* owner's mail — never the database's sentence, which names the address. An insert that fails
|
|
332
|
+
* while the address still has no account is a real failure and throws.
|
|
303
333
|
*/
|
|
304
334
|
async createAccount(account: {
|
|
305
335
|
name: string;
|
|
@@ -308,24 +338,39 @@ export class Signup implements SignupService {
|
|
|
308
338
|
emailVerified: boolean;
|
|
309
339
|
invitedBy: User['invitedBy'];
|
|
310
340
|
}): Promise<'created' | 'exists'> {
|
|
311
|
-
const db = getDbAsSystem();
|
|
312
341
|
const email = account.email.toLowerCase();
|
|
313
|
-
|
|
314
|
-
|
|
342
|
+
if (getMachineAccounts().some((machineAccount) => machineAccount.email === email)) {
|
|
343
|
+
throw new Error(`This address can't be registered.`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (await this.hasAccount(email)) {
|
|
315
347
|
return 'exists';
|
|
316
348
|
}
|
|
317
349
|
|
|
318
|
-
await
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
350
|
+
const password = await new PasswordHasher().hash(account.password);
|
|
351
|
+
try {
|
|
352
|
+
await getDbAsSystem().insert(tables.User, {
|
|
353
|
+
name: account.name,
|
|
354
|
+
email,
|
|
355
|
+
password,
|
|
356
|
+
emailVerified: account.emailVerified,
|
|
357
|
+
roles: [],
|
|
358
|
+
invitedBy: account.invitedBy,
|
|
359
|
+
});
|
|
360
|
+
} catch (error: unknown) {
|
|
361
|
+
if (await this.hasAccount(email)) {
|
|
362
|
+
return 'exists';
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
326
366
|
return 'created';
|
|
327
367
|
}
|
|
328
368
|
|
|
369
|
+
/** Whether an account holds `email` (already lowercased) — the one existence read `createAccount` makes. */
|
|
370
|
+
private async hasAccount(email: string): Promise<boolean> {
|
|
371
|
+
return Boolean(await getDbAsSystem().get(tables.User, { email }));
|
|
372
|
+
}
|
|
373
|
+
|
|
329
374
|
/** Resolves an invite token, distinguishing "expired" from "never existed / already revoked". */
|
|
330
375
|
private async lookupInvite(token: string): Promise<InviteLookup> {
|
|
331
376
|
const db = getDbAsSystem();
|
package/test/DevLogin.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
-
import {
|
|
2
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
3
|
+
import { MachineAccount, tables } from '@proteinjs/user';
|
|
3
4
|
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
4
5
|
import { invokeDevLogin } from './devLoginHarness';
|
|
5
6
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
@@ -107,6 +108,39 @@ describe('devLogin route', () => {
|
|
|
107
108
|
expect(await getUserRow('intruder@evil.example')).toBeUndefined();
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
it('refuses a ?email a machine-account declaration owns with 400 in plain words — no session, no row', async () => {
|
|
112
|
+
// The declared machine accounts of the build are the list signup refuses from; the dev door
|
|
113
|
+
// creates missing accounts through the same function and must answer the way its other
|
|
114
|
+
// refusals do — in words, not as an unhandled throw.
|
|
115
|
+
class DeclaredMachineAccount extends MachineAccount {
|
|
116
|
+
id = 'declared-machine';
|
|
117
|
+
email = 'declared.machine@test.local';
|
|
118
|
+
accountName = 'Declared machine';
|
|
119
|
+
roles = [];
|
|
120
|
+
secretName = 'declared-machine-secret';
|
|
121
|
+
}
|
|
122
|
+
const repo = SourceRepository.get() as unknown as { namedObjectCache: Record<string, unknown[]> };
|
|
123
|
+
repo.namedObjectCache['@proteinjs/db/SourceRecordLoader'] = [
|
|
124
|
+
{
|
|
125
|
+
qualifiedName: '@proteinjs/user-server-test/DeclaredMachineAccount',
|
|
126
|
+
packageName: '@test/declarer',
|
|
127
|
+
object: new DeclaredMachineAccount(),
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
try {
|
|
131
|
+
const outcome = await invokeDevLogin({ email: 'declared.machine@test.local' });
|
|
132
|
+
|
|
133
|
+
expect(outcome.status).toBe(400);
|
|
134
|
+
expect(String(outcome.body)).toContain(`This address can't be registered.`);
|
|
135
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
136
|
+
expect(outcome.sessionSaved).toBe(false);
|
|
137
|
+
expect(outcome.redirect).toBeUndefined();
|
|
138
|
+
expect(await getUserRow('declared.machine@test.local')).toBeUndefined();
|
|
139
|
+
} finally {
|
|
140
|
+
delete repo.namedObjectCache['@proteinjs/db/SourceRecordLoader'];
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
110
144
|
it('rejects a malformed same-domain ?email with 400 — no session, no stray account', async () => {
|
|
111
145
|
// The observed shape: an unencoded `+` in the query decodes to a space, so
|
|
112
146
|
// `?email=brent+shareproof-a@...` arrived as `brent shareproof-a@...` and minted a stray
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LogCapture } from './LogCapture';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The log capture's address reader — what `expect(log.addresses).toEqual([])` stands on. It reads
|
|
5
|
+
* an address in every spelling a log line can carry: bare, inside a name-and-address form, and
|
|
6
|
+
* URL-encoded (`%40` for the `@`, `%2B` for a `+`) as a logged request path carries it. A digest
|
|
7
|
+
* or a bare domain is not an address.
|
|
8
|
+
*/
|
|
9
|
+
describe('LogCapture.addresses', () => {
|
|
10
|
+
it('reads an address bare, in a name-and-address form, and URL-encoded in a logged path', async () => {
|
|
11
|
+
const log = await LogCapture.during(async () => {
|
|
12
|
+
console.log('Email sent successfully to ada@example.com');
|
|
13
|
+
console.info('Invite from "Grace" <grace.hopper@example.org>');
|
|
14
|
+
console.info('Started /dev/login?email=ada%2Blane%40example.com');
|
|
15
|
+
console.warn('Sign-in refused', { account: '0123456789abcdef', domain: 'example.com' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(log.addresses).toEqual(['ada@example.com', 'grace.hopper@example.org', 'ada%2Blane%40example.com']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('reads nothing from a log that names people by digest and domain only', async () => {
|
|
22
|
+
const log = await LogCapture.during(async () => {
|
|
23
|
+
console.info('Email sent — 1 recipient (example.com)', { recipients: ['0123456789abcdef'] });
|
|
24
|
+
console.info('Finished /user/login 200');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(log.addresses).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/test/LogCapture.ts
CHANGED
|
@@ -9,6 +9,12 @@ import { createHash } from 'crypto';
|
|
|
9
9
|
export class LogCapture {
|
|
10
10
|
/** How many leading characters of a token count as the token showing up in a log. */
|
|
11
11
|
private static readonly TOKEN_PREFIX_LENGTH = 12;
|
|
12
|
+
/**
|
|
13
|
+
* An e-mail address, as the house's log check reads one: bare, or URL-encoded (`%40` for the `@`)
|
|
14
|
+
* as a logged request path carries it — a check that read only the bare spelling missed the one
|
|
15
|
+
* place an address is known to reach a log, the request log's URL.
|
|
16
|
+
*/
|
|
17
|
+
private static readonly ADDRESS_SHAPE = /[A-Za-z0-9._%+-]+(?:@|%40)[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
12
18
|
|
|
13
19
|
private constructor(readonly lines: string[]) {}
|
|
14
20
|
|
|
@@ -38,6 +44,16 @@ export class LogCapture {
|
|
|
38
44
|
return this.lines.join('\n');
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
/** Every e-mail address the log holds, in order of appearance — a log names people by their digests, never by address. */
|
|
48
|
+
get addresses(): string[] {
|
|
49
|
+
return this.text.match(LogCapture.ADDRESS_SHAPE) ?? [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The capture narrowed to the writes that contain `text` — one door's lines, read with the same readers. */
|
|
53
|
+
linesContaining(text: string): LogCapture {
|
|
54
|
+
return new LogCapture(this.lines.filter((line) => line.includes(text)));
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* Fails unless the log is free of `token` in every form that would let a log reader act on it
|
|
43
59
|
* or tie a line to a row: the token, the SHA-256 digest a row stores for it, and the token's
|