@xeplr/auth 1.0.1 → 1.0.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/bin/migrate.js +25 -11
- package/bin/server.js +1 -1
- package/index.js +70 -14
- package/lib/authRouter.js +4 -1
- package/lib/authService.js +127 -13
- package/migrations/0007_license_module.sql +35 -0
- package/package.json +4 -1
package/bin/migrate.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { up, status } = require('@xeplr/db').sqlMigrator;
|
|
8
|
-
const { resolveConfig } = require('@xeplr/db');
|
|
8
|
+
const { resolveConfig, migrationsFor } = require('@xeplr/db');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* xeplr-auth-migrate
|
|
@@ -15,14 +15,24 @@ const { resolveConfig } = require('@xeplr/db');
|
|
|
15
15
|
* base data lives in migrations so it runs exactly once). Reuses xeplr-db's
|
|
16
16
|
* sqlMigrator — hand-written .sql files, no down(), ledger-tracked.
|
|
17
17
|
*
|
|
18
|
-
* The app points at its extension dir
|
|
19
|
-
* (or --extDir). Base migrations run first, then the
|
|
18
|
+
* The app points at its extension dir(s) via env: XEPLR_AUTH_MIGRATIONS
|
|
19
|
+
* (or --extDir). Base migrations run first, then the extension dir(s) — one
|
|
20
|
+
* shared ledger, tracked by filename.
|
|
21
|
+
*
|
|
22
|
+
* XEPLR_AUTH_MIGRATIONS accepts a COMMA-SEPARATED list, not just one path —
|
|
23
|
+
* this is how several consuming apps' extension migrations (workflow's,
|
|
24
|
+
* jobs', BI's own) can all land in the SAME target database from a single
|
|
25
|
+
* controlled run, without any one app's migrations folder having to contain
|
|
26
|
+
* another's:
|
|
27
|
+
* XEPLR_AUTH_MIGRATIONS=/path/to/bi/migrations-auth,/path/to/workflow/migrations-auth
|
|
28
|
+
* Sibling apps must keep their migration FILENAMES distinct from each
|
|
29
|
+
* other's — the ledger is keyed by filename alone, not by which directory it
|
|
30
|
+
* came from.
|
|
20
31
|
*
|
|
21
32
|
* Usage:
|
|
22
|
-
* xeplr-auth-migrate up [--extDir <dir>] [--db <database>]
|
|
23
|
-
* xeplr-auth-migrate status [--extDir <dir>] [--db <database>]
|
|
33
|
+
* xeplr-auth-migrate up [--extDir <dir>[,<dir>...]] [--db <database>]
|
|
34
|
+
* xeplr-auth-migrate status [--extDir <dir>[,<dir>...]] [--db <database>]
|
|
24
35
|
*/
|
|
25
|
-
|
|
26
36
|
function parseArgs(argv) {
|
|
27
37
|
const args = { _: [] };
|
|
28
38
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -42,13 +52,17 @@ async function main() {
|
|
|
42
52
|
const command = args._[0];
|
|
43
53
|
|
|
44
54
|
// Bundled auth migrations (base) + the app's extension migrations dir, which
|
|
45
|
-
// the app configures ONCE via
|
|
55
|
+
// the app configures ONCE via XEPLR_AUTH_MIGRATIONS (or --extDir). Base runs
|
|
46
56
|
// first, then the extension — one shared ledger.
|
|
47
57
|
const options = {
|
|
48
58
|
...args,
|
|
49
59
|
db: args.db || process.env.AUTH_DB_NAME,
|
|
50
60
|
dir: path.join(__dirname, '..', 'migrations'),
|
|
51
|
-
|
|
61
|
+
// Same convention as every other entry point and every other library —
|
|
62
|
+
// splitting and existence-checking live in @xeplr/db's
|
|
63
|
+
// resolveDirectories(), not here. This CLI having had its own parseExtDir
|
|
64
|
+
// while boot() had none is exactly how the two diverged.
|
|
65
|
+
extDir: args.extDir || args['ext-dir'] || migrationsFor('auth'),
|
|
52
66
|
connectionName: args['connection-name'] || args.connectionName || 'auth'
|
|
53
67
|
};
|
|
54
68
|
|
|
@@ -92,10 +106,10 @@ async function main() {
|
|
|
92
106
|
console.log('xeplr-auth-migrate - Auth database migrations');
|
|
93
107
|
console.log('');
|
|
94
108
|
console.log('Commands:');
|
|
95
|
-
console.log(' up [--extDir <dir>] Run base + app extension migrations');
|
|
96
|
-
console.log(' status [--extDir <dir>] Show migration status');
|
|
109
|
+
console.log(' up [--extDir <dir>[,<dir>...]] Run base + app extension migrations');
|
|
110
|
+
console.log(' status [--extDir <dir>[,<dir>...]] Show migration status');
|
|
97
111
|
console.log('');
|
|
98
|
-
console.log(' App extension dir: --extDir or
|
|
112
|
+
console.log(' App extension dir(s): --extDir or XEPLR_AUTH_MIGRATIONS env (comma-separated for more than one)');
|
|
99
113
|
console.log('');
|
|
100
114
|
console.log('Options:');
|
|
101
115
|
console.log(' --db Database name (or AUTH_DB_NAME env)');
|
package/bin/server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* env: ENCRYPTION_KEY · AUTH_DB_CONNECTION_INFO_ENCRYPTED · AUTH_DB_NAME ·
|
|
9
9
|
* AUTH_JWT_SECRET · AUTH_PORT · AUTH_ACTIVATION_BASE_URL ·
|
|
10
|
-
* AUTH_ACCESS_TOKEN_TTL_MINUTES ·
|
|
10
|
+
* AUTH_ACCESS_TOKEN_TTL_MINUTES · XEPLR_AUTH_MIGRATIONS · email vars · REDIS_*
|
|
11
11
|
*/
|
|
12
12
|
require('../index').boot().then(function () {
|
|
13
13
|
if (process.send) process.send({ status: 'ready' });
|
package/index.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
-
const { getConnection, bindModels, resolveConfig, sqlMigrator } = require('@xeplr/db');
|
|
2
|
+
const { getConnection, bindModels, resolveConfig, sqlMigrator, resolveDbConnection, describeDbConnection, migrationsFor, migrationsVar } = require('@xeplr/db');
|
|
3
|
+
|
|
4
|
+
// The connection comes from XEPLR_DB_CONNECTION unless this service overrides
|
|
5
|
+
// it with AUTH_DB_CONNECTION_INFO_ENCRYPTED — one server, one credential, with
|
|
6
|
+
// an escape hatch. AUTH_DB_NAME is untouched: which DATABASE is a separate
|
|
7
|
+
// question from where the server is, and auth keeps its own.
|
|
8
|
+
var AUTH_CONN_VAR = 'AUTH_DB_CONNECTION_INFO_ENCRYPTED';
|
|
9
|
+
function authConnection() { return resolveDbConnection(AUTH_CONN_VAR); }
|
|
3
10
|
const { createApp } = require('@xeplr/base-apis');
|
|
4
11
|
const authHelper = require('./lib/authHelper');
|
|
5
12
|
const { seedSuperAdmin } = require('./lib/seed');
|
|
@@ -107,19 +114,25 @@ async function start(config = {}) {
|
|
|
107
114
|
// system, so it reads its whole setup from AUTH_* env (see requiredEnv + README):
|
|
108
115
|
// connection AUTH_DB_CONNECTION_INFO_ENCRYPTED · db AUTH_DB_NAME · AUTH_JWT_SECRET
|
|
109
116
|
// AUTH_PORT · AUTH_ACTIVATION_BASE_URL · AUTH_ACCESS_TOKEN_TTL_MINUTES
|
|
110
|
-
//
|
|
117
|
+
// XEPLR_AUTH_MIGRATIONS · email vars · REDIS_*
|
|
111
118
|
async function boot() {
|
|
112
119
|
var connName = 'auth';
|
|
113
120
|
var database = process.env.AUTH_DB_NAME || 'auth';
|
|
114
121
|
|
|
115
|
-
await resolveConfig(connName,
|
|
122
|
+
await resolveConfig(connName, authConnection());
|
|
116
123
|
|
|
117
124
|
// Self-migrate: auth's own .sql migrations (schema + base data), then the
|
|
118
125
|
// app's extension dir. Idempotent — same as `xeplr-auth-migrate up`.
|
|
119
126
|
var migrationResult = await sqlMigrator.up({
|
|
120
127
|
db: database,
|
|
121
128
|
dir: path.join(__dirname, 'migrations'),
|
|
122
|
-
|
|
129
|
+
// The SHARED convention, not this library's own reading of the variable.
|
|
130
|
+
// Passing process.env.XEPLR_AUTH_MIGRATIONS straight through is what
|
|
131
|
+
// broke here: a comma-separated list became one nonexistent directory and
|
|
132
|
+
// this entry point silently applied none of BI's, workflow's or jobs'
|
|
133
|
+
// migrations, while the xeplr-auth-migrate CLI — which had its own
|
|
134
|
+
// splitting — applied all of them. Same variable, two behaviours.
|
|
135
|
+
extDir: migrationsFor('auth'),
|
|
123
136
|
type: 'precede',
|
|
124
137
|
connectionName: connName
|
|
125
138
|
});
|
|
@@ -129,12 +142,18 @@ async function boot() {
|
|
|
129
142
|
|
|
130
143
|
// Configure email (via @xeplr/utils, the engine auth uses) + activation links.
|
|
131
144
|
require('@xeplr/utils').configureFromEnv();
|
|
132
|
-
|
|
145
|
+
authService.configureActivation({
|
|
146
|
+
// Fully qualified, one per link — the token is the only thing appended.
|
|
147
|
+
// AUTH_ACTIVATION_BASE_URL still works as an origin; see configureActivation.
|
|
148
|
+
activationUrl: process.env.AUTH_ACTIVATION_URL || null,
|
|
149
|
+
inviteUrl: process.env.AUTH_INVITE_URL || null,
|
|
150
|
+
baseUrl: process.env.AUTH_ACTIVATION_BASE_URL || null
|
|
151
|
+
});
|
|
133
152
|
|
|
134
153
|
var server = await start({
|
|
135
154
|
database: database,
|
|
136
155
|
connectionName: connName,
|
|
137
|
-
connection:
|
|
156
|
+
connection: authConnection(),
|
|
138
157
|
port: process.env.AUTH_PORT,
|
|
139
158
|
jwt: {
|
|
140
159
|
secret: process.env.AUTH_JWT_SECRET,
|
|
@@ -153,15 +172,19 @@ function banner() {
|
|
|
153
172
|
var rows = [
|
|
154
173
|
['port', process.env.AUTH_PORT || '19001'],
|
|
155
174
|
['database', process.env.AUTH_DB_NAME || 'auth'],
|
|
156
|
-
['
|
|
157
|
-
['activation url', process.env.AUTH_ACTIVATION_BASE_URL || '(none)'],
|
|
175
|
+
[migrationsVar('auth'), migrationsFor('auth') || '(none)'],
|
|
176
|
+
['activation url', process.env.AUTH_ACTIVATION_URL || process.env.AUTH_ACTIVATION_BASE_URL || '(none)'],
|
|
177
|
+
['invite url', process.env.AUTH_INVITE_URL || '(derived)'],
|
|
158
178
|
['access token ttl', (process.env.AUTH_ACCESS_TOKEN_TTL_MINUTES || '15') + 'm'],
|
|
159
179
|
['refresh token ttl', (process.env.AUTH_REFRESH_TOKEN_TTL_DAYS || '7') + 'd'],
|
|
160
180
|
['slide tolerance', (process.env.AUTH_ACCESS_TOKEN_TOLERANCE_SECONDS || '0') + 's'],
|
|
161
181
|
['max sessions/user', process.env.AUTH_MAX_SESSIONS_PER_USER || '5'],
|
|
162
182
|
['redis', (process.env.REDIS_HOST || 'localhost') + ':' + (process.env.REDIS_PORT || '6379')],
|
|
163
183
|
['email', process.env.EMAIL_PROVIDER || '(not configured)'],
|
|
164
|
-
|
|
184
|
+
// Names the VARIABLE it resolved through, not just "set" — with a shared
|
|
185
|
+
// default and a per-service override, "which server am I on" is otherwise
|
|
186
|
+
// a guess.
|
|
187
|
+
['connection', describeDbConnection(AUTH_CONN_VAR) || '✗ NOT CONFIGURED'],
|
|
165
188
|
['encryption key', set(process.env.ENCRYPTION_KEY)],
|
|
166
189
|
['jwt secret', process.env.AUTH_JWT_SECRET ? '✓ set' : '✗ INSECURE DEFAULT']
|
|
167
190
|
];
|
|
@@ -195,10 +218,16 @@ function attach() {
|
|
|
195
218
|
var _ready = (async function () {
|
|
196
219
|
_conn = await getConnection(
|
|
197
220
|
process.env.AUTH_DB_NAME || 'auth',
|
|
198
|
-
|
|
221
|
+
authConnection(),
|
|
199
222
|
{ bind: false, connectionName: 'auth' }
|
|
200
223
|
);
|
|
201
|
-
|
|
224
|
+
authService.configureActivation({
|
|
225
|
+
// Fully qualified, one per link — the token is the only thing appended.
|
|
226
|
+
// AUTH_ACTIVATION_BASE_URL still works as an origin; see configureActivation.
|
|
227
|
+
activationUrl: process.env.AUTH_ACTIVATION_URL || null,
|
|
228
|
+
inviteUrl: process.env.AUTH_INVITE_URL || null,
|
|
229
|
+
baseUrl: process.env.AUTH_ACTIVATION_BASE_URL || null
|
|
230
|
+
});
|
|
202
231
|
return _conn;
|
|
203
232
|
})();
|
|
204
233
|
|
|
@@ -219,14 +248,41 @@ function attach() {
|
|
|
219
248
|
var requiredEnv = [
|
|
220
249
|
'ENCRYPTION_KEY',
|
|
221
250
|
'AUTH_JWT_SECRET',
|
|
222
|
-
|
|
251
|
+
// NOT listed literally: the activation link can be given either as
|
|
252
|
+
// AUTH_ACTIVATION_URL (fully qualified, preferred) or as the older
|
|
253
|
+
// AUTH_ACTIVATION_BASE_URL (an origin). checkEnv takes a list of names and
|
|
254
|
+
// demands every one, which would refuse to boot an install that configured
|
|
255
|
+
// the new form. The getter below asks for whichever is actually in use.
|
|
223
256
|
'AUTH_PORT',
|
|
224
257
|
'AUTH_DB_NAME', // auth db (api reaches it via attach())
|
|
225
|
-
'
|
|
226
|
-
'AUTH_EXT_MIGRATIONS_DIR', // migrations run via migrate:up
|
|
258
|
+
'XEPLR_AUTH_MIGRATIONS', // migrations run via migrate:up
|
|
227
259
|
];
|
|
228
260
|
|
|
261
|
+
// Read at ACCESS time so it reflects the .env the app has already loaded — the
|
|
262
|
+
// same reason @xeplr/email's requiredEnv is a getter. Names the form the
|
|
263
|
+
// install is actually using, and asks for the new one when neither is set.
|
|
264
|
+
Object.defineProperty(requiredEnv, 'activationLinkVar', {
|
|
265
|
+
enumerable: false,
|
|
266
|
+
get: function () {
|
|
267
|
+
return process.env.AUTH_ACTIVATION_BASE_URL && !process.env.AUTH_ACTIVATION_URL
|
|
268
|
+
? 'AUTH_ACTIVATION_BASE_URL'
|
|
269
|
+
: 'AUTH_ACTIVATION_URL';
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// NOT in requiredEnv, deliberately: AUTH_DB_CONNECTION_INFO_ENCRYPTED is now an
|
|
274
|
+
// OVERRIDE. The connection normally comes from the shared XEPLR_DB_CONNECTION,
|
|
275
|
+
// so demanding the auth-specific name would fail a correctly configured
|
|
276
|
+
// install. Missing-ness is caught at the point of use by resolveDbConnection,
|
|
277
|
+
// which names both variables.
|
|
278
|
+
//
|
|
279
|
+
// AUTH_DB_NAME stays required and stays auth's own — the DATABASE is a
|
|
280
|
+
// different question from the SERVER, and services do not share one.
|
|
281
|
+
|
|
229
282
|
module.exports = {
|
|
283
|
+
// Hand in @xeplr-workflow/api's resumeByKey to release a step that is
|
|
284
|
+
// waiting for someone to activate. Omitted, activation just activates.
|
|
285
|
+
configureWorkflowResume: authService.configureWorkflowResume,
|
|
230
286
|
requiredEnv,
|
|
231
287
|
init,
|
|
232
288
|
attach,
|
package/lib/authRouter.js
CHANGED
|
@@ -48,7 +48,10 @@ function createAuthRouter(options = {}) {
|
|
|
48
48
|
return res.status(400).json({ error: 'Activation token is required' });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Carried in the activation LINK and handed straight back — see
|
|
52
|
+
// authService.register. If a workflow is waiting on this person, this is
|
|
53
|
+
// the key that releases it.
|
|
54
|
+
var result = await activate(token, { workflowKey: req.query.workflowKey });
|
|
52
55
|
res.json({ message: 'Account activated successfully', user: result });
|
|
53
56
|
} catch (err) {
|
|
54
57
|
logError(req, err);
|
package/lib/authService.js
CHANGED
|
@@ -7,13 +7,92 @@ const { getUserAccess, clearUserAccess } = require('./accessService');
|
|
|
7
7
|
const { createSession, destroyAllUserSessions } = require('./sessionService');
|
|
8
8
|
const hooks = require('./hooks');
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// WHERE THE LINKS IN OUR EMAILS POINT.
|
|
11
|
+
//
|
|
12
|
+
// FULLY QUALIFIED, one per link, because these strings do not only end up in a
|
|
13
|
+
// string concatenation here — they are handed to the email/template layer,
|
|
14
|
+
// where the token can travel as a variable but the URL cannot. A template that
|
|
15
|
+
// has to know to append '/auth/activate' to something called a "base" is a
|
|
16
|
+
// template that knows about this package's routing.
|
|
17
|
+
//
|
|
18
|
+
// The old AUTH_ACTIVATION_BASE_URL still works: given an origin, the default
|
|
19
|
+
// path for each link is appended, exactly as before. It served both the
|
|
20
|
+
// activation and the invite page, which is precisely why one fully-qualified
|
|
21
|
+
// value could not replace it.
|
|
22
|
+
var _links = { activationUrl: null, inviteUrl: null, baseUrl: null };
|
|
23
|
+
|
|
24
|
+
var DEFAULT_PATHS = { activation: '/auth/activate', invite: '/auth/accept-invite' };
|
|
25
|
+
|
|
26
|
+
// RESUMING A WORKFLOW ON ACTIVATION.
|
|
27
|
+
//
|
|
28
|
+
// A registration can be one step of a longer process — "invite them, wait for
|
|
29
|
+
// them to activate, then grant the roles" — and a workflow step that waits is
|
|
30
|
+
// released by its resumeKey (@xeplr-workflow/api's resumeByKey).
|
|
31
|
+
//
|
|
32
|
+
// INJECTED, not required. This package sits underneath every product here and
|
|
33
|
+
// must not depend on one of them; a host that runs workflows hands the
|
|
34
|
+
// function in at boot, and an install without workflow simply never sets it
|
|
35
|
+
// and never sees the feature. Same shape as configureEmail above.
|
|
36
|
+
var _resumeWorkflowByKey = null;
|
|
37
|
+
|
|
38
|
+
function configureWorkflowResume(fn) {
|
|
39
|
+
_resumeWorkflowByKey = typeof fn === 'function' ? fn : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort, and deliberately so.
|
|
44
|
+
*
|
|
45
|
+
* The account IS activated by the time this runs. If the workflow cannot be
|
|
46
|
+
* resumed — no such key, the run already moved on, workflow is down — that is
|
|
47
|
+
* a problem with the workflow, not with the person who just clicked the link
|
|
48
|
+
* in their email. Failing the activation over it would lock them out of an
|
|
49
|
+
* account that is already open.
|
|
50
|
+
*/
|
|
51
|
+
async function resumeWorkflow(workflowKey, output) {
|
|
52
|
+
if (!workflowKey || !_resumeWorkflowByKey) return null;
|
|
53
|
+
try {
|
|
54
|
+
return await _resumeWorkflowByKey(workflowKey, output);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Through @xeplr/logs, which is what routes an error onto the host's
|
|
57
|
+
// problem list (see xeplr-bi's errorEvents) — a workflow that silently
|
|
58
|
+
// never resumed is exactly the kind of thing nobody goes looking for.
|
|
59
|
+
require('@xeplr/logs').createLogger('auth')
|
|
60
|
+
.error('could not resume workflow "' + workflowKey + '": ' + err.message);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
11
64
|
|
|
12
|
-
|
|
13
|
-
|
|
65
|
+
/**
|
|
66
|
+
* @param {string|object} config - a legacy origin string, or
|
|
67
|
+
* { activationUrl, inviteUrl, baseUrl }.
|
|
68
|
+
*/
|
|
69
|
+
function configureActivation(config) {
|
|
70
|
+
if (typeof config === 'string') { _links.baseUrl = config; return; }
|
|
71
|
+
if (!config) return;
|
|
72
|
+
if (config.activationUrl !== undefined) _links.activationUrl = config.activationUrl;
|
|
73
|
+
if (config.inviteUrl !== undefined) _links.inviteUrl = config.inviteUrl;
|
|
74
|
+
if (config.baseUrl !== undefined) _links.baseUrl = config.baseUrl;
|
|
14
75
|
}
|
|
15
76
|
|
|
16
|
-
|
|
77
|
+
function linkFor(kind) {
|
|
78
|
+
var explicit = kind === 'activation'
|
|
79
|
+
? (_links.activationUrl || process.env.AUTH_ACTIVATION_URL)
|
|
80
|
+
: (_links.inviteUrl || process.env.AUTH_INVITE_URL);
|
|
81
|
+
if (explicit) return explicit;
|
|
82
|
+
|
|
83
|
+
var base = _links.baseUrl || process.env.AUTH_ACTIVATION_BASE_URL;
|
|
84
|
+
if (!base) return null;
|
|
85
|
+
// Trailing slashes are the most common way a hand-edited .env produces a
|
|
86
|
+
// double slash in a link somebody then reports as broken.
|
|
87
|
+
return String(base).replace(/\/+$/, '') + DEFAULT_PATHS[kind];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Append the token, respecting a URL that already carries a query string. */
|
|
91
|
+
function withToken(url, token) {
|
|
92
|
+
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'token=' + token;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function register({ email, phoneNumber, name, password, workflowKey, knex }) {
|
|
17
96
|
const UserM = knex ? User.bindKnex(knex) : User;
|
|
18
97
|
const normalized = normalizeEmail(email);
|
|
19
98
|
|
|
@@ -47,12 +126,17 @@ async function register({ email, phoneNumber, name, password, knex }) {
|
|
|
47
126
|
});
|
|
48
127
|
|
|
49
128
|
// Send activation email
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
52
|
-
throw new Error('
|
|
129
|
+
const activationUrl = linkFor('activation');
|
|
130
|
+
if (!activationUrl) {
|
|
131
|
+
throw new Error('AUTH_ACTIVATION_URL is required (the full address of your activation page, ' +
|
|
132
|
+
'e.g. http://localhost:19100/auth/activate). Cannot register without email activation.');
|
|
53
133
|
}
|
|
54
134
|
|
|
55
|
-
|
|
135
|
+
// The key rides along in the link so it comes back to us when the user
|
|
136
|
+
// clicks — there is nowhere else to keep it: activation happens in a
|
|
137
|
+
// different browser, days later, with no session.
|
|
138
|
+
var activationLink = withToken(activationUrl, activationToken);
|
|
139
|
+
if (workflowKey) activationLink += '&workflowKey=' + encodeURIComponent(workflowKey);
|
|
56
140
|
const html = '<h2>Activate your account</h2>'
|
|
57
141
|
+ '<p>Hi ' + (name || 'there') + ',</p>'
|
|
58
142
|
+ '<p>Click below to activate your account:</p>'
|
|
@@ -72,16 +156,29 @@ async function register({ email, phoneNumber, name, password, knex }) {
|
|
|
72
156
|
};
|
|
73
157
|
}
|
|
74
158
|
|
|
75
|
-
async function activate(token) {
|
|
159
|
+
async function activate(token, options) {
|
|
160
|
+
var workflowKey = options && options.workflowKey;
|
|
161
|
+
var log = require('@xeplr/logs').createLogger('auth');
|
|
162
|
+
|
|
163
|
+
// THE TOKEN IS NEVER LOGGED. It is a bearer credential — anyone holding the
|
|
164
|
+
// string can activate that account — and a log line is the one place it
|
|
165
|
+
// would sit in plain text long after the token itself was nulled. That a
|
|
166
|
+
// token arrived, and how it ended, is what is worth recording.
|
|
167
|
+
log.info('activation: token received' + (workflowKey ? ', workflowKey "' + workflowKey + '"' : ', no workflowKey'));
|
|
168
|
+
|
|
76
169
|
const user = await User.query().findOne({ activationToken: token });
|
|
77
170
|
if (!user) {
|
|
171
|
+
log.warn('activation: no user holds that token — expired, already used, or wrong link');
|
|
78
172
|
throw new Error('Invalid activation token');
|
|
79
173
|
}
|
|
80
174
|
|
|
81
175
|
if (user.isActivated) {
|
|
176
|
+
log.info('activation: ' + user.email + ' is already activated — nothing to do');
|
|
82
177
|
throw new Error('Account already activated');
|
|
83
178
|
}
|
|
84
179
|
|
|
180
|
+
log.info('activation: activating ' + user.email);
|
|
181
|
+
|
|
85
182
|
const now = formatDbDateTime();
|
|
86
183
|
await User.query().findById(user.id).patch({
|
|
87
184
|
isActivated: true,
|
|
@@ -90,8 +187,23 @@ async function activate(token) {
|
|
|
90
187
|
recordModifiedDate: now
|
|
91
188
|
});
|
|
92
189
|
|
|
190
|
+
log.important('activation: ' + user.email + ' activated');
|
|
93
191
|
await hooks.fire('user', 'update', user.id);
|
|
94
192
|
|
|
193
|
+
// AFTER the patch, never before: a workflow released by this step may go on
|
|
194
|
+
// to grant roles or send a welcome mail, and it must not act on an account
|
|
195
|
+
// that has not actually been activated yet.
|
|
196
|
+
if (workflowKey) {
|
|
197
|
+
log.info('activation: resuming workflow "' + workflowKey + '"');
|
|
198
|
+
var resumed = await resumeWorkflow(workflowKey,
|
|
199
|
+
{ userId: user.id, email: user.email, name: user.name });
|
|
200
|
+
if (resumed) {
|
|
201
|
+
log.important('activation: workflow "' + workflowKey + '" resumed (run ' +
|
|
202
|
+
(resumed.runId || '?') + ', step ' + (resumed.stepKey || '?') + ')');
|
|
203
|
+
}
|
|
204
|
+
// The failure path already logged its own reason inside resumeWorkflow.
|
|
205
|
+
}
|
|
206
|
+
|
|
95
207
|
return { id: user.id, email: user.email, name: user.name };
|
|
96
208
|
}
|
|
97
209
|
|
|
@@ -194,12 +306,13 @@ async function invite({ email, name, phoneNumber, invitedBy, inviterName, appNam
|
|
|
194
306
|
recordModifiedBy: inviter
|
|
195
307
|
});
|
|
196
308
|
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
199
|
-
throw new Error('
|
|
309
|
+
const inviteUrl = linkFor('invite');
|
|
310
|
+
if (!inviteUrl) {
|
|
311
|
+
throw new Error('AUTH_INVITE_URL is required to send invite emails (the full address of your ' +
|
|
312
|
+
'accept-invite page, e.g. http://localhost:19100/auth/accept-invite).');
|
|
200
313
|
}
|
|
201
314
|
|
|
202
|
-
const acceptLink =
|
|
315
|
+
const acceptLink = withToken(inviteUrl, activationToken);
|
|
203
316
|
const app = appName || 'the app';
|
|
204
317
|
const subject = 'You have been invited to ' + app;
|
|
205
318
|
const html = '<h2>You have been invited</h2>'
|
|
@@ -494,6 +607,7 @@ async function changePassword(userId, { oldPassword, newPassword }) {
|
|
|
494
607
|
|
|
495
608
|
module.exports = {
|
|
496
609
|
configureActivation,
|
|
610
|
+
configureWorkflowResume,
|
|
497
611
|
configureEmail,
|
|
498
612
|
register,
|
|
499
613
|
activate,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Which SOLD MODULE each catalog row belongs to.
|
|
2
|
+
--
|
|
3
|
+
-- This is a second, independent axis from the existing *Group columns:
|
|
4
|
+
--
|
|
5
|
+
-- apiGroup / menuGroup / ... = what FUNCTION it is. Roles grant it, and the
|
|
6
|
+
-- CUSTOMER'S admin controls those roles.
|
|
7
|
+
-- licenseModule = which PRODUCT it was sold as. Only Xeplr
|
|
8
|
+
-- writes this, and no role can escape it.
|
|
9
|
+
--
|
|
10
|
+
-- Effective access is the intersection: a company admin holding
|
|
11
|
+
-- 'configuration:view' still cannot see the Warehouse menu if the company is
|
|
12
|
+
-- not licensed for the warehouse module. That matters because Super Admin is
|
|
13
|
+
-- granted every menu by an unfiltered CROSS JOIN (see xeplr-bi's
|
|
14
|
+
-- 0007_warehouse_menu.sql) — so entitlement CANNOT be expressed as a role.
|
|
15
|
+
--
|
|
16
|
+
-- Deliberately not called "service": that word already means "which package
|
|
17
|
+
-- wrote this row" (attachConfig({ service })), and applicationId already means
|
|
18
|
+
-- "which deployment owns it". This is a third thing — which line item on the
|
|
19
|
+
-- price list — and reusing either name would collide with a documented meaning.
|
|
20
|
+
--
|
|
21
|
+
-- Default 'core' on purpose. Login, company selection, the account menu and the
|
|
22
|
+
-- home page belong to no product; if they were tagged 'bi' an unlicensed
|
|
23
|
+
-- company could not even sign in to see that it is unlicensed. Backfilling
|
|
24
|
+
-- everything to 'core' means adding this column changes no behaviour at all
|
|
25
|
+
-- until rows are deliberately re-tagged.
|
|
26
|
+
|
|
27
|
+
ALTER TABLE "apis" ADD COLUMN IF NOT EXISTS "licenseModule" varchar(50) NOT NULL DEFAULT 'core';
|
|
28
|
+
ALTER TABLE "menus" ADD COLUMN IF NOT EXISTS "licenseModule" varchar(50) NOT NULL DEFAULT 'core';
|
|
29
|
+
ALTER TABLE "uiPages" ADD COLUMN IF NOT EXISTS "licenseModule" varchar(50) NOT NULL DEFAULT 'core';
|
|
30
|
+
ALTER TABLE "uiElements" ADD COLUMN IF NOT EXISTS "licenseModule" varchar(50) NOT NULL DEFAULT 'core';
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "apis_licenseModule_index" ON "apis" ("licenseModule");
|
|
33
|
+
CREATE INDEX IF NOT EXISTS "menus_licenseModule_index" ON "menus" ("licenseModule");
|
|
34
|
+
CREATE INDEX IF NOT EXISTS "uiPages_licenseModule_index" ON "uiPages" ("licenseModule");
|
|
35
|
+
CREATE INDEX IF NOT EXISTS "uiElements_licenseModule_index" ON "uiElements" ("licenseModule");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/auth",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Pluggable authentication service: register, login, forgot/reset password, RBAC, multi-tenancy",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"express": "^4.0.0 || ^5.0.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "node --test test/*.test.js"
|
|
51
54
|
}
|
|
52
55
|
}
|