nodebb-plugin-anti-account-sharing 1.0.4 → 1.0.5
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/library.js +106 -67
- package/package.json +1 -1
- package/static/security.js +188 -79
package/library.js
CHANGED
|
@@ -1,91 +1,130 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const winston = require.main.require('winston');
|
|
5
|
+
const meta = require.main.require('./src/meta');
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
+
const PLUGIN_ID = 'nodebb-plugin-anti-account-sharing';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
enabled: true,
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// Fingerprint değişince ne yapalım?
|
|
13
|
+
// "kick" => diğer oturumları kır
|
|
14
|
+
// "block" => isteği 403 ile engelle (giriş yapanı bile)
|
|
15
|
+
enforcement: 'kick',
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Aynı kullanıcı için fingerprint değişimini kaç saniye "şüpheli" sayalım?
|
|
18
|
+
// Örn: 300 => son 5 dakikada farklı fingerprint görürse tetikle
|
|
19
|
+
windowSeconds: 300,
|
|
19
20
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
const req = data?.req;
|
|
23
|
-
const uid = data?.uid;
|
|
21
|
+
// Log seviyesi: "debug" | "info" | "warn" | "error"
|
|
22
|
+
logLevel: 'info',
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Fingerprint: ip + user-agent (+ accept-language opsiyonel)
|
|
25
|
+
includeAcceptLanguage: false,
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Reverse-proxy arkasında doğru ip için
|
|
28
|
+
trustProxy: true,
|
|
29
|
+
};
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
function toBool(v) {
|
|
32
|
+
if (typeof v === 'boolean') return v;
|
|
33
|
+
if (typeof v === 'string') return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase());
|
|
34
|
+
return !!v;
|
|
35
|
+
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
function toInt(v, dflt) {
|
|
38
|
+
const n = parseInt(v, 10);
|
|
39
|
+
return Number.isFinite(n) ? n : dflt;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getSettings() {
|
|
43
|
+
// ACP -> Settings -> Anti Account Sharing (meta settings) varsa buradan okur
|
|
44
|
+
// Yoksa defaults kullanır.
|
|
45
|
+
let s = {};
|
|
46
|
+
try {
|
|
47
|
+
s = await meta.settings.get(PLUGIN_ID);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
s = {};
|
|
42
50
|
}
|
|
43
|
-
};
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
const out = {
|
|
53
|
+
enabled: toBool(s.enabled ?? DEFAULTS.enabled),
|
|
54
|
+
enforcement: (s.enforcement || DEFAULTS.enforcement).toLowerCase(),
|
|
55
|
+
windowSeconds: toInt(s.windowSeconds ?? DEFAULTS.windowSeconds, DEFAULTS.windowSeconds),
|
|
56
|
+
logLevel: (s.logLevel || DEFAULTS.logLevel).toLowerCase(),
|
|
57
|
+
includeAcceptLanguage: toBool(s.includeAcceptLanguage ?? DEFAULTS.includeAcceptLanguage),
|
|
58
|
+
trustProxy: toBool(s.trustProxy ?? DEFAULTS.trustProxy),
|
|
59
|
+
};
|
|
49
60
|
|
|
50
|
-
if (!
|
|
51
|
-
if (!
|
|
61
|
+
if (!['kick', 'block'].includes(out.enforcement)) out.enforcement = DEFAULTS.enforcement;
|
|
62
|
+
if (!['debug', 'info', 'warn', 'error'].includes(out.logLevel)) out.logLevel = DEFAULTS.logLevel;
|
|
63
|
+
if (out.windowSeconds < 0) out.windowSeconds = DEFAULTS.windowSeconds;
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
function sha1(input) {
|
|
69
|
+
return crypto.createHash('sha1').update(String(input)).digest('hex');
|
|
70
|
+
}
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
function getClientIp(req, trustProxy) {
|
|
73
|
+
// NodeBB/Express: trust proxy açık değilse x-forwarded-for güvenilmez olur.
|
|
74
|
+
if (trustProxy) {
|
|
75
|
+
const xff = (req.headers['x-forwarded-for'] || '').toString();
|
|
76
|
+
if (xff) return xff.split(',')[0].trim();
|
|
77
|
+
}
|
|
78
|
+
return (
|
|
79
|
+
req.ip ||
|
|
80
|
+
(req.connection && req.connection.remoteAddress) ||
|
|
81
|
+
(req.socket && req.socket.remoteAddress) ||
|
|
82
|
+
''
|
|
83
|
+
);
|
|
84
|
+
}
|
|
60
85
|
|
|
61
|
-
|
|
62
|
-
const
|
|
86
|
+
function getFingerprint(req, settings) {
|
|
87
|
+
const ip = getClientIp(req, settings.trustProxy);
|
|
88
|
+
const ua = (req.headers['user-agent'] || '').toString();
|
|
89
|
+
const al = (req.headers['accept-language'] || '').toString();
|
|
90
|
+
const raw = settings.includeAcceptLanguage ? `${ip}|${ua}|${al}` : `${ip}|${ua}`;
|
|
91
|
+
return sha1(raw);
|
|
92
|
+
}
|
|
63
93
|
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
function buildLogger(settings) {
|
|
95
|
+
// winston ile NodeBB loglarına düşer, ayrıca console’a da basar.
|
|
96
|
+
const level = settings?.logLevel || 'info';
|
|
66
97
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
}
|
|
98
|
+
function log(lvl, msg, metaObj) {
|
|
99
|
+
const line = `[AAS] ${msg}${metaObj ? ` ${safeJson(metaObj)}` : ''}`;
|
|
100
|
+
// NodeBB winston
|
|
101
|
+
if (winston && typeof winston[lvl] === 'function') winston[lvl](line);
|
|
102
|
+
else if (winston && typeof winston.info === 'function') winston.info(line);
|
|
73
103
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
// console fallback (docker logs)
|
|
105
|
+
if (lvl === 'error') console.error(line);
|
|
106
|
+
else if (lvl === 'warn') console.warn(line);
|
|
107
|
+
else console.log(line);
|
|
108
|
+
}
|
|
78
109
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
110
|
+
return {
|
|
111
|
+
debug: (msg, metaObj) => (['debug'].includes(level) ? log('info', `DEBUG: ${msg}`, metaObj) : null),
|
|
112
|
+
info: (msg, metaObj) => (['debug', 'info'].includes(level) ? log('info', msg, metaObj) : null),
|
|
113
|
+
warn: (msg, metaObj) => (['debug', 'info', 'warn'].includes(level) ? log('warn', msg, metaObj) : null),
|
|
114
|
+
error: (msg, metaObj) => log('error', msg, metaObj),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
83
117
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return data;
|
|
89
|
-
};
|
|
118
|
+
function safeJson(x) {
|
|
119
|
+
try { return JSON.stringify(x); } catch (e) { return '[unjsonable]'; }
|
|
120
|
+
}
|
|
90
121
|
|
|
91
|
-
module.exports =
|
|
122
|
+
module.exports = {
|
|
123
|
+
PLUGIN_ID,
|
|
124
|
+
DEFAULTS,
|
|
125
|
+
getSettings,
|
|
126
|
+
getFingerprint,
|
|
127
|
+
getClientIp,
|
|
128
|
+
sha1,
|
|
129
|
+
buildLogger,
|
|
130
|
+
};
|
package/package.json
CHANGED
package/static/security.js
CHANGED
|
@@ -1,97 +1,206 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<a href="/login" style="
|
|
38
|
-
display: block; width: 100%; padding: 14px 0;
|
|
39
|
-
background: #d9534f; color: #fff; text-decoration: none;
|
|
40
|
-
font-weight: 600; border-radius: 8px; font-size: 15px;
|
|
41
|
-
transition: background 0.2s;
|
|
42
|
-
" onmouseover="this.style.background='#c9302c'" onmouseout="this.style.background='#d9534f'">
|
|
43
|
-
TEKRAR GİRİŞ YAP
|
|
44
|
-
</a>
|
|
45
|
-
|
|
46
|
-
<div style="margin-top: 20px; font-size: 12px; color: #999;">
|
|
47
|
-
Mobil cihazlarınız bu durumdan etkilenmez.
|
|
48
|
-
</div>
|
|
49
|
-
</div>
|
|
50
|
-
</div>
|
|
51
|
-
<style>@keyframes fadeIn { from { opacity:0; transform:scale(0.95); } to { opacity:1; transform:scale(1); } }</style>
|
|
52
|
-
`;
|
|
53
|
-
|
|
54
|
-
$('body').append(modalHtml);
|
|
55
|
-
$('body').css('overflow', 'hidden');
|
|
3
|
+
const db = require.main.require('./src/database');
|
|
4
|
+
const user = require.main.require('./src/user');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
getSettings,
|
|
8
|
+
getFingerprint,
|
|
9
|
+
buildLogger,
|
|
10
|
+
PLUGIN_ID,
|
|
11
|
+
} = require('./library');
|
|
12
|
+
|
|
13
|
+
const KEY_LAST_FP = (uid) => `uid:${uid}:aas:lastfp`;
|
|
14
|
+
const KEY_LAST_SEEN = (uid) => `uid:${uid}:aas:lastseen`; // epoch seconds
|
|
15
|
+
const KEY_LAST_IP = (uid) => `uid:${uid}:aas:lastip`;
|
|
16
|
+
const KEY_LAST_UA = (uid) => `uid:${uid}:aas:lastua`;
|
|
17
|
+
|
|
18
|
+
const SESS_SET = (uid) => `uid:${uid}:sessions`;
|
|
19
|
+
|
|
20
|
+
// NodeBB’de yaygın session obj key isimleri (kuruluma göre değişebiliyor)
|
|
21
|
+
function sessionObjectKeys(sid) {
|
|
22
|
+
return [
|
|
23
|
+
`sess:${sid}`,
|
|
24
|
+
`session:${sid}`,
|
|
25
|
+
`sessions:${sid}`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getUserSessionIds(uid) {
|
|
30
|
+
// NodeBB genelde sorted set tutar
|
|
31
|
+
try {
|
|
32
|
+
const sids = await db.getSortedSetRange(SESS_SET(uid), 0, -1);
|
|
33
|
+
return Array.isArray(sids) ? sids : [];
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return [];
|
|
56
36
|
}
|
|
37
|
+
}
|
|
57
38
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
39
|
+
async function deleteSession(sid, logger) {
|
|
40
|
+
// session obj key’leri silmeyi dener
|
|
41
|
+
for (const k of sessionObjectKeys(sid)) {
|
|
42
|
+
try {
|
|
43
|
+
await db.delete(k);
|
|
44
|
+
logger.debug('Deleted session object', { key: k });
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// ignore
|
|
62
47
|
}
|
|
63
48
|
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function kickOtherSessions(uid, keepSid, logger) {
|
|
52
|
+
const all = await getUserSessionIds(uid);
|
|
53
|
+
if (!all.length) return { total: 0, removed: 0 };
|
|
64
54
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
55
|
+
let removed = 0;
|
|
56
|
+
for (const sid of all) {
|
|
57
|
+
if (!sid) continue;
|
|
58
|
+
if (keepSid && sid === keepSid) continue;
|
|
68
59
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
try {
|
|
61
|
+
await db.sortedSetRemove(SESS_SET(uid), sid);
|
|
62
|
+
removed += 1;
|
|
63
|
+
logger.info('Removed session from uid sessions set', { uid, sid });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
logger.warn('Failed removing session from sessions set', { uid, sid, err: e.message });
|
|
73
66
|
}
|
|
74
|
-
});
|
|
75
67
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
await deleteSession(sid, logger);
|
|
69
|
+
}
|
|
70
|
+
return { total: all.length, removed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getCurrentSessionId(req) {
|
|
74
|
+
// Express-session genelde req.sessionID verir
|
|
75
|
+
if (req && req.sessionID) return req.sessionID;
|
|
76
|
+
|
|
77
|
+
// Cookie’den yakalamayı dene (connect.sid vb.)
|
|
78
|
+
const cookie = (req.headers && req.headers.cookie) ? String(req.headers.cookie) : '';
|
|
79
|
+
// connect.sid=s%3Axxxxx.yyyyy
|
|
80
|
+
const m = cookie.match(/connect\.sid=([^;]+)/i);
|
|
81
|
+
if (m && m[1]) return decodeURIComponent(m[1]).replace(/^s:/, '').split('.')[0];
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function shouldTrigger(uid, fp, nowSec, settings) {
|
|
86
|
+
const [lastfp, lastseen] = await Promise.all([
|
|
87
|
+
db.getObjectField(KEY_LAST_FP(uid), 'v'),
|
|
88
|
+
db.getObjectField(KEY_LAST_SEEN(uid), 'v'),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const lastSeenNum = parseInt(lastseen, 10);
|
|
92
|
+
const lastFpStr = lastfp ? String(lastfp) : '';
|
|
93
|
+
|
|
94
|
+
if (!lastFpStr) return { trigger: false, reason: 'no_last_fp' };
|
|
95
|
+
if (lastFpStr === fp) return { trigger: false, reason: 'same_fp' };
|
|
96
|
+
|
|
97
|
+
if (!Number.isFinite(lastSeenNum)) return { trigger: true, reason: 'fp_changed_no_lastseen' };
|
|
98
|
+
|
|
99
|
+
const diff = nowSec - lastSeenNum;
|
|
100
|
+
if (diff <= settings.windowSeconds) {
|
|
101
|
+
return { trigger: true, reason: `fp_changed_within_${settings.windowSeconds}s` };
|
|
102
|
+
}
|
|
103
|
+
return { trigger: false, reason: `fp_changed_outside_window(${diff}s)` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function updateLast(uid, fp, req, nowSec) {
|
|
107
|
+
const ip = (req.headers && (req.headers['x-forwarded-for'] || req.ip || '')) || '';
|
|
108
|
+
const ua = (req.headers && req.headers['user-agent']) || '';
|
|
80
109
|
|
|
110
|
+
await Promise.allSettled([
|
|
111
|
+
db.setObjectField(KEY_LAST_FP(uid), 'v', fp),
|
|
112
|
+
db.setObjectField(KEY_LAST_SEEN(uid), 'v', String(nowSec)),
|
|
113
|
+
db.setObjectField(KEY_LAST_IP(uid), 'v', String(ip).slice(0, 200)),
|
|
114
|
+
db.setObjectField(KEY_LAST_UA(uid), 'v', String(ua).slice(0, 300)),
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createSecurityMiddleware(settings, logger) {
|
|
119
|
+
return async function antiAccountSharingMiddleware(req, res, next) {
|
|
81
120
|
try {
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
121
|
+
if (!settings.enabled) return next();
|
|
122
|
+
|
|
123
|
+
// NodeBB: req.uid login olmuş kullanıcı
|
|
124
|
+
const uid = parseInt(req.uid, 10);
|
|
125
|
+
if (!Number.isFinite(uid) || uid <= 0) return next();
|
|
126
|
+
|
|
127
|
+
// Bazı route’larda user obj yoksa da problem değil
|
|
128
|
+
// (Bunu loglamak istersen aç)
|
|
129
|
+
// const u = await user.getUserFields(uid, ['username']);
|
|
130
|
+
// const username = u?.username;
|
|
131
|
+
|
|
132
|
+
const fp = getFingerprint(req, settings);
|
|
133
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
134
|
+
|
|
135
|
+
const check = await shouldTrigger(uid, fp, nowSec, settings);
|
|
136
|
+
|
|
137
|
+
if (check.trigger) {
|
|
138
|
+
const keepSid = getCurrentSessionId(req);
|
|
86
139
|
|
|
87
|
-
|
|
88
|
-
|
|
140
|
+
logger.warn('Account sharing suspected (fingerprint changed)', {
|
|
141
|
+
uid,
|
|
142
|
+
reason: check.reason,
|
|
143
|
+
keepSid: keepSid || '',
|
|
144
|
+
path: req.originalUrl || req.url,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (settings.enforcement === 'block') {
|
|
148
|
+
// current request’i de engelle
|
|
149
|
+
await updateLast(uid, fp, req, nowSec);
|
|
150
|
+
res.status(403).json({
|
|
151
|
+
error: 'account-sharing-detected',
|
|
152
|
+
message: 'Bu hesap farklı cihaz/ağ üzerinden aynı anda kullanılıyor olabilir.',
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
89
155
|
}
|
|
156
|
+
|
|
157
|
+
// kick mode: diğer session’ları kır
|
|
158
|
+
const result = await kickOtherSessions(uid, keepSid, logger);
|
|
159
|
+
logger.info('Kick result', { uid, ...result });
|
|
160
|
+
|
|
161
|
+
// (İsteğe bağlı) burada kullanıcıya bildirim de basılabilir
|
|
90
162
|
}
|
|
163
|
+
|
|
164
|
+
// her request sonunda last’i güncelle
|
|
165
|
+
await updateLast(uid, fp, req, nowSec);
|
|
166
|
+
return next();
|
|
91
167
|
} catch (e) {
|
|
92
|
-
|
|
168
|
+
logger.error('Middleware error', { err: e.message });
|
|
169
|
+
return next();
|
|
93
170
|
}
|
|
94
|
-
|
|
95
|
-
return res;
|
|
96
171
|
};
|
|
97
|
-
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const Security = {};
|
|
175
|
+
|
|
176
|
+
Security.init = async function (params) {
|
|
177
|
+
// NodeBB hook: static:app.load (en yaygın)
|
|
178
|
+
// params: { app, router, middleware, controllers }
|
|
179
|
+
const { app } = params;
|
|
180
|
+
const settings = await getSettings();
|
|
181
|
+
const logger = buildLogger(settings);
|
|
182
|
+
|
|
183
|
+
logger.info('Loading security middleware', {
|
|
184
|
+
enabled: settings.enabled,
|
|
185
|
+
enforcement: settings.enforcement,
|
|
186
|
+
windowSeconds: settings.windowSeconds,
|
|
187
|
+
trustProxy: settings.trustProxy,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!app) {
|
|
191
|
+
logger.warn('No express app passed to security.init (static:app.load?)');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// trust proxy opsiyonel (reverse proxy varsa doğru ip için)
|
|
196
|
+
try {
|
|
197
|
+
app.set('trust proxy', !!settings.trustProxy);
|
|
198
|
+
} catch (e) {}
|
|
199
|
+
|
|
200
|
+
// NodeBB’nin auth middleware’inden sonra çalışması için genelde app.use yeterli
|
|
201
|
+
app.use(createSecurityMiddleware(settings, logger));
|
|
202
|
+
|
|
203
|
+
logger.info('Security middleware attached', { plugin: PLUGIN_ID });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
module.exports = Security;
|