Skip to content

Commit c28af61

Browse files
author
Hans Kristian Flaatten
committed
feat(user): move cache management to User class
BREAKING CHANGE: API exposed by User classes rewritten.
1 parent 5f198e9 commit c28af61

File tree

9 files changed

+513
-395
lines changed

9 files changed

+513
-395
lines changed

index.js

+25-107
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,44 @@
11
'use strict';
22

33
const HttpError = require('@starefossen/http-error');
4-
const redis = require('@turbasen/db-redis');
5-
const mongo = require('@turbasen/db-mongo');
64

7-
const UnauthUser = require('./lib/User').UnauthUser;
8-
const AuthUser = require('./lib/User').AuthUser;
9-
10-
const CACHE_VALID = process.env.NTB_CACHE_VALID || 60 * 60 * 1000;
11-
const CACHE_INVALID = process.env.NTB_CACHE_INVALID || 24 * 60 * 60 * 1000;
12-
const API_ENV = process.env.NTB_API_ENV || 'dev';
13-
const RATELIMIT_UNAUTH = process.env.NTB_RATELIMIT_UNAUTH || 100;
5+
const AbstractUser = require('./lib/AbstractUser');
6+
const UnauthUser = require('./lib/UnauthUser');
7+
const AuthUser = require('./lib/AuthUser');
148

159
module.exports = () => (req, res, next) => {
1610
let promise;
1711

1812
// API key through Authorization header
1913
if (req.headers.authorization) {
2014
const token = req.headers.authorization.split(' ');
21-
promise = module.exports.getUserByToken(token[1]);
15+
promise = AuthUser.getByKey(token[1]);
2216

2317
// API key through URL query parameter
2418
} else if (req.query && req.query.api_key) {
25-
promise = module.exports.getUserByToken(req.query.api_key);
19+
promise = AuthUser.getByKey(req.query.api_key);
2620

2721
// No API key
2822
} else {
29-
promise = module.exports.getUserByIp(
23+
promise = UnauthUser.getByKey(
3024
req.headers['x-forwarded-for'] || req.connection.remoteAddres
3125
);
3226
}
3327

3428
promise.then(user => {
3529
req.user = user;
3630

31+
// X-User headers
3732
res.set('X-User-Auth', user.auth);
3833
if (user.auth) {
3934
res.set('X-User-Provider', user.provider);
4035
}
4136

37+
// X-Rate-Limit headers
4238
res.set('X-RateLimit-Limit', user.limit);
4339
res.set('X-RateLimit-Reset', user.reset);
4440

41+
// Check if user has remaining rate limit quota
4542
if (!user.hasRemainingQuota()) {
4643
res.set('X-RateLimit-Remaining', 0);
4744

@@ -50,115 +47,36 @@ module.exports = () => (req, res, next) => {
5047
));
5148
}
5249

50+
// Charge user for this request
5351
res.set('X-RateLimit-Remaining', user.charge());
5452

53+
// Check if user can execute the HTTP method. Only authenticated users are
54+
// allowed to execute POST, PUT, and DELETE requests.
5555
if (!user.can(req.method)) {
5656
return next(new HttpError(
5757
401, `API authentication required for "${req.method}" requests`
5858
));
5959
}
6060

61-
res.on('finish', function resOnFinishCb() {
62-
// Uncharge user when certain cache features are used.
63-
// 304 Not Modified, and 412 Precondition Failed
64-
if (this.statusCode === 304 || this.statusCode === 412) {
65-
this.req.user.uncharge();
66-
}
67-
68-
if (this.req.user.getCharge() > 0) {
69-
redis.hincrby(this.req.user.cacheKey, 'remaining', -1);
70-
}
71-
});
61+
// Attach the on finish callback which updates the user rate limit in cache.
62+
res.on('finish', module.exports.onFinish);
7263

7364
return next();
7465
}).catch(next);
7566
};
7667

77-
module.exports.getUserByIp = function getUserByIp(key) {
78-
return new Promise((resolve, reject) => {
79-
redis.hgetall(AuthUser.getCacheKey(key), (redisErr, data) => {
80-
if (redisErr) { return reject(redisErr); }
81-
82-
if (data && data.limit) {
83-
return resolve(new UnauthUser(key, data));
84-
} else {
85-
const expireat = module.exports.expireat(CACHE_VALID);
86-
87-
const user = new UnauthUser(key, {
88-
limit: RATELIMIT_UNAUTH,
89-
remaining: RATELIMIT_UNAUTH,
90-
reset: expireat,
91-
});
92-
93-
redis.hmset(AuthUser.getCacheKey(key), user.toObject());
94-
redis.expireat(AuthUser.getCacheKey(key), expireat);
95-
96-
return resolve(user);
97-
}
98-
});
99-
});
100-
};
101-
102-
module.exports.getUserByToken = function getUserByToken(key) {
103-
return new Promise((resolve, reject) => {
104-
redis.hgetall(UnauthUser.getCacheKey(key), (redisErr, data) => {
105-
if (redisErr) { return reject(redisErr); }
106-
107-
if (data && data.access) {
108-
if (data.access === 'true') {
109-
return resolve(new AuthUser(key, data));
110-
} else {
111-
return reject(new HttpError(`Bad credentials for user "${key}"`, 401));
112-
}
113-
}
114-
115-
const query = {
116-
[`apps.key.${API_ENV}`]: key,
117-
'apps.active': true,
118-
};
119-
120-
const opts = {
121-
fields: {
122-
provider: true,
123-
apps: true,
124-
},
125-
};
126-
127-
return mongo.api.users.findOne(query, opts, (mongoErr, doc) => {
128-
if (mongoErr) { return reject(mongoErr); }
129-
130-
if (!doc) {
131-
const expireat = module.exports.expireat(CACHE_INVALID);
132-
133-
redis.hset(AuthUser.getCacheKey(key), 'access', 'false');
134-
redis.expireat(AuthUser.getCacheKey(key), expireat);
135-
136-
return reject(new HttpError(`Bad credentials for user "${key}"`, 401));
137-
}
138-
139-
const app = doc.apps.find(item => item.key[API_ENV] === key);
140-
const expireat = module.exports.expireat(CACHE_VALID);
141-
142-
const user = new AuthUser(key, {
143-
provider: doc.provider,
144-
app: app.name,
145-
146-
limit: app.limit[API_ENV],
147-
remaining: app.limit[API_ENV],
148-
reset: expireat,
149-
});
150-
151-
redis.hmset(AuthUser.getCacheKey(key), user.toObject());
152-
redis.expireat(AuthUser.getCacheKey(key), expireat);
153-
154-
return resolve(user);
155-
});
156-
});
157-
});
158-
};
68+
module.exports.onFinish = function onFinish() {
69+
// Uncharge user when certain cache features are used.
70+
// 304 Not Modified, and 412 Precondition Failed
71+
if (this.statusCode === 304 || this.statusCode === 412) {
72+
this.req.user.uncharge();
73+
}
15974

160-
module.exports.expireat = function expireat(seconds) {
161-
return Math.floor((new Date().getTime() + seconds) / 1000);
75+
// Update user rate-limit in cache if it has changed.
76+
this.req.user.update();
16277
};
16378

16479
module.exports.middleware = module.exports();
80+
module.exports.AbstractUser = AbstractUser;
81+
module.exports.AuthUser = AuthUser;
82+
module.exports.UnauthUser = UnauthUser;

lib/AbstractUser.js

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use strict';
2+
3+
const redis = require('@turbasen/db-redis');
4+
5+
const CACHE_VALID = process.env.NTB_CACHE_VALID || 60 * 60 * 1000;
6+
7+
class AbstractUser {
8+
constructor(type, key, data) {
9+
this.type = type;
10+
this.key = key;
11+
12+
this.provider = data.provider || '';
13+
this.app = data.app || '';
14+
15+
this.limit = parseInt(data.limit, 10);
16+
this.remaining = parseInt(data.remaining, 10);
17+
this.reset = parseInt(data.reset, 10) || module.exports.expireat(CACHE_VALID);
18+
19+
this.penalty = 0;
20+
}
21+
22+
get cacheKey() {
23+
return AbstractUser.getCacheKey(this.type, this.key);
24+
}
25+
26+
toObject() {
27+
return {
28+
access: 'true',
29+
30+
provider: this.provider,
31+
app: this.app,
32+
33+
limit: `${this.limit}`,
34+
remaining: `${this.remaining - this.penalty}`,
35+
reset: `${this.reset}`,
36+
};
37+
}
38+
39+
save() {
40+
redis.hmset(this.cacheKey, this.toObject());
41+
redis.expireat(this.cacheKey, this.reset);
42+
43+
return this;
44+
}
45+
46+
update() {
47+
if (this.penalty > 0) {
48+
redis.hincrby(this.cacheKey, 'remaining', this.penalty);
49+
}
50+
}
51+
52+
charge() {
53+
this.penalty = this.penalty + 1;
54+
return this.remaining - this.penalty;
55+
}
56+
57+
uncharge() {
58+
this.penalty = 0;
59+
return this.remaining;
60+
}
61+
62+
getCharge() {
63+
return this.penalty;
64+
}
65+
66+
hasRemainingQuota() {
67+
return this.remaining - this.penalty > 0;
68+
}
69+
70+
static getCacheKey(type, key) {
71+
return `api:${type}:${key}`;
72+
}
73+
}
74+
75+
module.exports = AbstractUser;
76+
module.exports.expireat = s => Math.floor((new Date().getTime() + s) / 1000);

lib/AuthUser.js

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use strict';
2+
3+
const mongo = require('@turbasen/db-mongo');
4+
const redis = require('@turbasen/db-redis');
5+
const HttpError = require('@starefossen/http-error');
6+
const AbstractUser = require('./AbstractUser');
7+
8+
const CACHE_INVALID = process.env.NTB_CACHE_INVALID || 24 * 60 * 60 * 1000;
9+
const API_ENV = process.env.NTB_API_ENV || 'dev';
10+
11+
class AuthUser extends AbstractUser {
12+
constructor(token, data) {
13+
super('token', token, data);
14+
}
15+
16+
get auth() { return true; }
17+
18+
get name() {
19+
return `${this.provider} (${this.app})`;
20+
}
21+
22+
isOwner(doc) {
23+
return !!doc.tilbyder && this.provider === doc.tilbyder;
24+
}
25+
26+
can() {
27+
return true;
28+
}
29+
30+
query(query) {
31+
const publicStatus = new Set('Offentlig', 'Slettet');
32+
33+
if (query.tilbyder) {
34+
if (query.tilbyder === this.provider) {
35+
return query;
36+
} else {
37+
return Object.assign(query, { status: 'Offentlig' });
38+
}
39+
} else if (query.status) {
40+
if (publicStatus.has(query.status)) {
41+
return query;
42+
} else {
43+
return Object.assign(query, { tilbyder: this.provider });
44+
}
45+
} else {
46+
return Object.assign(query, {
47+
$or: [{ tilbyder: this.provider }, { status: 'Offentlig' }],
48+
});
49+
}
50+
}
51+
52+
static getCacheKey(key) {
53+
return super.getCacheKey('token', key);
54+
}
55+
56+
static getFromMongo(key) {
57+
return new Promise((resolve, reject) => {
58+
const query = { [`apps.key.${API_ENV}`]: key, 'apps.active': true };
59+
const opts = { fields: { provider: true, apps: true } };
60+
61+
mongo.api.users.findOne(query, opts).then(doc => {
62+
if (!doc) {
63+
const expireat = AbstractUser.expireat(CACHE_INVALID);
64+
65+
redis.hset(AuthUser.getCacheKey(key), 'access', 'false');
66+
redis.expireat(AuthUser.getCacheKey(key), expireat);
67+
68+
reject(new HttpError(`Bad credentials for user "${key}"`, 401));
69+
} else {
70+
const app = doc.apps.find(item => item.key[API_ENV] === key);
71+
72+
const user = new AuthUser(key, {
73+
provider: doc.provider,
74+
app: app.name,
75+
76+
limit: app.limit[API_ENV],
77+
remaining: app.limit[API_ENV],
78+
});
79+
80+
resolve(user.save());
81+
}
82+
}).catch(reject);
83+
});
84+
}
85+
86+
static getByKey(key) {
87+
return new Promise((resolve, reject) => {
88+
redis.hgetall(AuthUser.getCacheKey(key)).then(data => {
89+
if (data && data.access) {
90+
if (data.access === 'true') {
91+
resolve(new AuthUser(key, data));
92+
} else {
93+
reject(new HttpError(`Bad credentials for user "${key}"`, 401));
94+
}
95+
} else {
96+
AuthUser.getFromMongo(key).then(resolve).catch(reject);
97+
}
98+
}).catch(reject);
99+
});
100+
}
101+
}
102+
103+
module.exports = AuthUser;

0 commit comments

Comments
 (0)