@proteinjs/user-server 1.22.2 → 1.22.3
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 +15 -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 +5 -0
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +28 -6
- 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 +717 -0
- package/dist/test/NoAddressesInLogs.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 +30 -4
- 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 +409 -0
- package/test/SignupRoute.test.ts +54 -1
- package/test/SocketIOSessionWatcher.test.ts +86 -0
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,11 @@ 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.
|
|
303
325
|
*/
|
|
304
326
|
async createAccount(account: {
|
|
305
327
|
name: string;
|
|
@@ -310,6 +332,10 @@ export class Signup implements SignupService {
|
|
|
310
332
|
}): Promise<'created' | 'exists'> {
|
|
311
333
|
const db = getDbAsSystem();
|
|
312
334
|
const email = account.email.toLowerCase();
|
|
335
|
+
if (getMachineAccounts().some((machineAccount) => machineAccount.email === email)) {
|
|
336
|
+
throw new Error(`This address can't be registered.`);
|
|
337
|
+
}
|
|
338
|
+
|
|
313
339
|
const existingUser = await db.get(tables.User, { email });
|
|
314
340
|
if (existingUser) {
|
|
315
341
|
return 'exists';
|
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
|
|
@@ -6,6 +6,7 @@ import { SourceRepository } from '@proteinjs/reflection';
|
|
|
6
6
|
import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
|
|
7
7
|
import { MachineAccount, RoleCatalogEntry, User, UserRepo, tables } from '@proteinjs/user';
|
|
8
8
|
import { authenticate } from '../src/authentication/authenticate';
|
|
9
|
+
import { SocketIOSessionWatcher } from '../src/authentication/SocketIOSessionWatcher';
|
|
9
10
|
import { UserStatusTableWatcher } from '../src/authentication/UserStatusTableWatcher';
|
|
10
11
|
import { MachineCredentials } from '../src/services/MachineCredentials';
|
|
11
12
|
import { Roles } from '../src/services/Roles';
|
|
@@ -48,10 +49,12 @@ const resetTableWatcherMap = () => {
|
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Machine accounts as source records, at the user layer: declare-only on the REAL mixed user
|
|
51
|
-
* table,
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* table, a declared address held by a row the sync does not own (a person's signup, a hand-made
|
|
53
|
+
* row) REFUSED — never taken over, its credential and sessions never inherited — and said so on
|
|
54
|
+
* the admin list and the mint; removed-from-source auto-deactivation with session kill through
|
|
55
|
+
* the categorical UserStatusTableWatcher, completing at boot before any socket server exists;
|
|
56
|
+
* the machine/human ledger split (Roles refuses machine targets), declaration validation, and
|
|
57
|
+
* credential minting (hash-only at rest, plaintext shown once).
|
|
55
58
|
*/
|
|
56
59
|
describe('Machine accounts as source records', () => {
|
|
57
60
|
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
@@ -90,8 +93,10 @@ describe('Machine accounts as source records', () => {
|
|
|
90
93
|
'@proteinjs/user-auth/AuthenticatedUserRepo'
|
|
91
94
|
] = [new UserRepo()];
|
|
92
95
|
objectCache()['@proteinjs/user/RoleCatalogEntry'] = [new OpsRole()];
|
|
93
|
-
//
|
|
94
|
-
|
|
96
|
+
// The two session watchers a real server registers, rebuilt from the seeded cache — and no
|
|
97
|
+
// socket server exists in this process: the shape of a boot's sync (and of a migration Job,
|
|
98
|
+
// which never starts one), where deactivations delete sessions before the server is up.
|
|
99
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher(), new SocketIOSessionWatcher()];
|
|
95
100
|
resetTableWatcherMap();
|
|
96
101
|
}, 120000);
|
|
97
102
|
|
|
@@ -139,32 +144,102 @@ describe('Machine accounts as source records', () => {
|
|
|
139
144
|
expect(humanAfter).toMatchObject({ name: 'A human', roles: ['ops'] });
|
|
140
145
|
});
|
|
141
146
|
|
|
142
|
-
it('
|
|
143
|
-
//
|
|
144
|
-
//
|
|
147
|
+
it(`a person's row holding a declared address refuses the declaration: no roles, no machine flag, their password and sessions stand; the list and the mint say so`, async () => {
|
|
148
|
+
// A person registered the address before the declaration shipped (inviteless signup): their
|
|
149
|
+
// own row, their own password, a live session — never a machine.
|
|
150
|
+
const person = await testEnv.createUser({ name: 'A person', email: 'machine-ops@test.local' });
|
|
151
|
+
await insertSession('person-session', 'machine-ops@test.local');
|
|
152
|
+
class Sibling extends TestOpsMachineAccount {
|
|
153
|
+
id = 'machine-sibling';
|
|
154
|
+
email = 'machine-sibling@test.local';
|
|
155
|
+
accountName = 'Sibling machine';
|
|
156
|
+
secretName = 'sibling-secret';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The boot completes and the rest of the declarations land.
|
|
160
|
+
await boot([new TestOpsMachineAccount(), new Sibling()]);
|
|
161
|
+
expect(await getDbAsSystem().get(tables.User, { email: 'machine-sibling@test.local' })).toMatchObject({
|
|
162
|
+
roles: ['ops'],
|
|
163
|
+
isLoadedFromSource: true,
|
|
164
|
+
machine: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The person's row is untouched: none of the declared roles, not a machine, not source-owned.
|
|
168
|
+
const row = await machineRow();
|
|
169
|
+
expect(row.id).toBe(person.id);
|
|
170
|
+
expect(row).toMatchObject({ name: 'A person', roles: [], emailVerified: true });
|
|
171
|
+
expect(row.machine).toBeFalsy();
|
|
172
|
+
expect(row.isLoadedFromSource).toBeFalsy();
|
|
173
|
+
expect(row.password).toBe(person.password);
|
|
174
|
+
expect(row.updated.valueOf()).toBe(person.updated.valueOf());
|
|
175
|
+
expect(await sessionEmails()).toEqual(['machine-ops@test.local']);
|
|
176
|
+
|
|
177
|
+
// The admin list names the refusal; the mint refuses in the same words and changes nothing.
|
|
178
|
+
const view = (await new MachineCredentials().listMachineAccounts()).find(
|
|
179
|
+
(account) => account.email === 'machine-ops@test.local'
|
|
180
|
+
);
|
|
181
|
+
expect(view).toMatchObject({
|
|
182
|
+
status: 'declaration refused',
|
|
183
|
+
refusal: 'a person holds this address',
|
|
184
|
+
hasCredential: false,
|
|
185
|
+
});
|
|
186
|
+
await expect(new MachineCredentials().mintCredential('machine-ops@test.local')).rejects.toThrow(
|
|
187
|
+
/declaration for 'machine-ops@test\.local' was refused at boot: a person holds this address/
|
|
188
|
+
);
|
|
189
|
+
expect((await machineRow()).password).toBe(person.password);
|
|
190
|
+
expect(await sessionEmails()).toEqual(['machine-ops@test.local']);
|
|
191
|
+
|
|
192
|
+
// Idempotent: the next boot refuses again and still grants nothing.
|
|
193
|
+
await boot([new TestOpsMachineAccount(), new Sibling()]);
|
|
194
|
+
const again = await machineRow();
|
|
195
|
+
expect(again).toMatchObject({ roles: [] });
|
|
196
|
+
expect(again.isLoadedFromSource).toBeFalsy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('a hand-made machine row the sync never owned is refused too — no row is taken over by a declaration any more', async () => {
|
|
200
|
+
// The pre-declaration provisioning shape: a row made by hand and marked machine, with a
|
|
201
|
+
// provisioned credential and runtime-granted roles.
|
|
145
202
|
const handMade = await testEnv.createUser({
|
|
146
203
|
name: 'Hand-made bridge',
|
|
147
204
|
email: 'machine-ops@test.local',
|
|
148
205
|
roles: ['ops', 'stale-role'],
|
|
149
206
|
});
|
|
207
|
+
await getDbAsSystem().update(tables.User, { id: handMade.id, machine: true });
|
|
150
208
|
|
|
151
209
|
await boot([new TestOpsMachineAccount()]);
|
|
152
210
|
|
|
153
|
-
const
|
|
154
|
-
expect(
|
|
155
|
-
|
|
156
|
-
name: '
|
|
157
|
-
roles: ['ops'],
|
|
158
|
-
status: 'active',
|
|
159
|
-
isLoadedFromSource: true,
|
|
160
|
-
// Adoption stamps the explicit machine column onto the hand-made row (the deployed-env
|
|
161
|
-
// provisioning shape — e.g. a hand-provisioned deploy account becomes honest on boot).
|
|
211
|
+
const row = await machineRow();
|
|
212
|
+
expect(row).toMatchObject({
|
|
213
|
+
id: handMade.id,
|
|
214
|
+
name: 'Hand-made bridge',
|
|
215
|
+
roles: ['ops', 'stale-role'],
|
|
162
216
|
machine: true,
|
|
163
217
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
expect(
|
|
218
|
+
expect(row.isLoadedFromSource).toBeFalsy();
|
|
219
|
+
expect(row.password).toBe(handMade.password);
|
|
220
|
+
const [view] = await new MachineCredentials().listMachineAccounts();
|
|
221
|
+
expect(view).toMatchObject({
|
|
222
|
+
status: 'declaration refused',
|
|
223
|
+
refusal: 'a hand-made machine row holds this address',
|
|
224
|
+
hasCredential: false,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('withdrawing a declaration while its account holds a live session: the boot completes before any socket server exists — the row deactivated, the session gone', async () => {
|
|
229
|
+
class Keeper extends TestOpsMachineAccount {
|
|
230
|
+
id = 'machine-keeper';
|
|
231
|
+
email = 'machine-keeper@test.local';
|
|
232
|
+
accountName = 'Keeper machine';
|
|
233
|
+
secretName = 'keeper-secret';
|
|
234
|
+
}
|
|
235
|
+
await boot([new TestOpsMachineAccount(), new Keeper()]);
|
|
236
|
+
await insertSession('machine-live-session', 'machine-ops@test.local');
|
|
237
|
+
|
|
238
|
+
// The declaration is withdrawn; the package still boots with its other declaration.
|
|
239
|
+
await boot([new Keeper()]);
|
|
240
|
+
|
|
241
|
+
expect((await machineRow()).status).toBe('deactivated');
|
|
242
|
+
expect(await sessionEmails()).toEqual([]);
|
|
168
243
|
});
|
|
169
244
|
|
|
170
245
|
it('removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates', async () => {
|