nodebb-plugin-anti-account-sharing 1.0.4 → 1.0.6

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.
Files changed (3) hide show
  1. package/library.js +104 -72
  2. package/package.json +1 -1
  3. package/static/security.js +220 -79
package/library.js CHANGED
@@ -1,91 +1,123 @@
1
1
  'use strict';
2
2
 
3
- const db = require.main.require('./src/database');
4
- const user = require.main.require('./src/user');
5
-
6
- const Plugin = {};
7
-
8
- // --- AYARLAR ---
9
- const MAX_DEVICES = 1; // kaç bilgisayar?
10
-
11
- function isMobile(req) {
12
- const ua = req?.headers?.['user-agent'] || '';
13
- return /Mobile|Android|iPhone|iPad|iPod/i.test(ua);
14
- }
15
-
16
- Plugin.init = async function () {
17
- console.log(`[Anti-Share] Aktif ✅ (Limit: ${MAX_DEVICES} PC)`);
3
+ const crypto = require('crypto');
4
+ const winston = require.main.require('winston');
5
+ const meta = require.main.require('./src/meta');
6
+
7
+ const PLUGIN_ID = 'nodebb-plugin-anti-account-sharing';
8
+
9
+ const DEFAULTS = {
10
+ enabled: true,
11
+ enforcement: 'kick',
12
+ windowSeconds: 300,
13
+ logLevel: 'info',
14
+ includeAcceptLanguage: false,
15
+ trustProxy: true,
18
16
  };
19
17
 
20
- // Login olduğunda session ekle (req gelmezse bile sorun değil, aşağıda fallback var)
21
- Plugin.recordSession = async function (data) {
22
- const req = data?.req;
23
- const uid = data?.uid;
24
-
25
- if (!req || !uid || !req.sessionID) return;
26
- if (isMobile(req)) return;
27
-
28
- const key = `antishare:sessionsz:${uid}`; // <-- DİKKAT: yeni key (zset)
29
- const sid = req.sessionID;
18
+ function toBool(v) {
19
+ if (typeof v === 'boolean') return v;
20
+ if (typeof v === 'string') return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase());
21
+ return !!v;
22
+ }
30
23
 
31
- // Eğer yoksa ekle (varsa score'u güncelleme!)
32
- const all = await db.getSortedSetRevRange(key, 0, -1);
33
- if (!all.includes(sid)) {
34
- await db.sortedSetAdd(key, Date.now(), sid);
35
- }
24
+ function toInt(v, dflt) {
25
+ const n = parseInt(v, 10);
26
+ return Number.isFinite(n) ? n : dflt;
27
+ }
36
28
 
37
- // sadece son MAX_DEVICES tut
38
- const newest = await db.getSortedSetRevRange(key, 0, -1);
39
- const toRemove = newest.slice(MAX_DEVICES);
40
- if (toRemove.length) {
41
- await db.sortedSetRemove(key, toRemove);
29
+ async function getSettings() {
30
+ let s = {};
31
+ try {
32
+ s = await meta.settings.get(PLUGIN_ID);
33
+ } catch (e) {
34
+ s = {};
42
35
  }
43
- };
44
36
 
45
- // Her page + api isteğinde kontrol
46
- Plugin.checkSession = async function (data) {
47
- const req = data?.req;
48
- const res = data?.res;
37
+ const out = {
38
+ enabled: toBool(s.enabled ?? DEFAULTS.enabled),
39
+ enforcement: (s.enforcement || DEFAULTS.enforcement).toLowerCase(),
40
+ windowSeconds: toInt(s.windowSeconds ?? DEFAULTS.windowSeconds, DEFAULTS.windowSeconds),
41
+ logLevel: (s.logLevel || DEFAULTS.logLevel).toLowerCase(),
42
+ includeAcceptLanguage: toBool(s.includeAcceptLanguage ?? DEFAULTS.includeAcceptLanguage),
43
+ trustProxy: toBool(s.trustProxy ?? DEFAULTS.trustProxy),
44
+ };
49
45
 
50
- if (!req || !res) return data;
51
- if (!req.uid || req.uid <= 0 || isMobile(req)) return data;
46
+ if (!['kick', 'block'].includes(out.enforcement)) out.enforcement = DEFAULTS.enforcement;
47
+ if (!['debug', 'info', 'warn', 'error'].includes(out.logLevel)) out.logLevel = DEFAULTS.logLevel;
48
+ if (out.windowSeconds < 0) out.windowSeconds = DEFAULTS.windowSeconds;
52
49
 
53
- const isAdmin = await user.isAdministrator(req.uid);
54
- if (isAdmin) return data;
55
-
56
- const sid = req.sessionID;
57
- if (!sid) return data;
50
+ return out;
51
+ }
58
52
 
59
- const key = `antishare:sessionsz:${req.uid}`;
53
+ function sha1(input) {
54
+ return crypto.createHash('sha1').update(String(input)).digest('hex');
55
+ }
60
56
 
61
- // Sadece en yeni MAX_DEVICES session allowed
62
- const allowed = await db.getSortedSetRevRange(key, 0, MAX_DEVICES - 1);
57
+ function getClientIp(req, trustProxy) {
58
+ if (trustProxy) {
59
+ const xff = (req.headers['x-forwarded-for'] || '').toString();
60
+ if (xff) return xff.split(',')[0].trim();
61
+ }
62
+ return (
63
+ req.ip ||
64
+ (req.connection && req.connection.remoteAddress) ||
65
+ (req.socket && req.socket.remoteAddress) ||
66
+ ''
67
+ );
68
+ }
63
69
 
64
- // Eğer henüz kayıt yoksa (çok nadir) karışma
65
- if (!Array.isArray(allowed) || !allowed.length) return data;
70
+ function getFingerprint(req, settings) {
71
+ const ip = getClientIp(req, settings.trustProxy);
72
+ const ua = (req.headers['user-agent'] || '').toString();
73
+ const al = (req.headers['accept-language'] || '').toString();
74
+ const raw = settings.includeAcceptLanguage ? `${ip}|${ua}|${al}` : `${ip}|${ua}`;
75
+ return sha1(raw);
76
+ }
66
77
 
67
- // allowed değilse -> kick
68
- if (!allowed.includes(sid)) {
69
- try { req.logout?.(); } catch (e) {}
70
- if (req.session) {
71
- try { req.session.destroy(() => {}); } catch (e) {}
72
- }
78
+ function safeJson(x) {
79
+ try { return JSON.stringify(x); } catch (e) { return '[unjsonable]'; }
80
+ }
73
81
 
74
- const wantsJson =
75
- req.xhr ||
76
- (req.headers.accept && req.headers.accept.includes('json')) ||
77
- req.originalUrl?.startsWith('/api/');
82
+ function buildLogger(settings) {
83
+ const level = settings?.logLevel || 'info';
78
84
 
79
- if (wantsJson) {
80
- res.status(401).json({ error: 'session_terminated', message: 'Maksimum cihaz sınırına ulaşıldı.' });
81
- return;
82
- }
85
+ function log(lvl, msg, metaObj) {
86
+ const line = `[AAS] ${msg}${metaObj ? ` ${safeJson(metaObj)}` : ''}`;
87
+ if (winston && typeof winston[lvl] === 'function') winston[lvl](line);
88
+ else if (winston && typeof winston.info === 'function') winston.info(line);
83
89
 
84
- res.redirect('/login?error=session-conflict');
85
- return;
90
+ if (lvl === 'error') console.error(line);
91
+ else if (lvl === 'warn') console.warn(line);
92
+ else console.log(line);
86
93
  }
87
-
88
- return data;
89
- };
90
94
 
91
- module.exports = Plugin;
95
+ return {
96
+ debug: (msg, metaObj) => (['debug'].includes(level) ? log('info', `DEBUG: ${msg}`, metaObj) : null),
97
+ info: (msg, metaObj) => (['debug', 'info'].includes(level) ? log('info', msg, metaObj) : null),
98
+ warn: (msg, metaObj) => (['debug', 'info', 'warn'].includes(level) ? log('warn', msg, metaObj) : null),
99
+ error: (msg, metaObj) => log('error', msg, metaObj),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * ✅ IMPORTANT: Hook'ları burada export et
105
+ * security.js içinde bunlar tanımlı olacak.
106
+ */
107
+ const Security = require('./security');
108
+
109
+ module.exports = {
110
+ // helpers
111
+ PLUGIN_ID,
112
+ DEFAULTS,
113
+ getSettings,
114
+ getFingerprint,
115
+ getClientIp,
116
+ sha1,
117
+ buildLogger,
118
+
119
+ // hooks (plugin.json bunları arıyor)
120
+ init: Security.init,
121
+ recordSession: Security.recordSession,
122
+ checkSession: Security.checkSession,
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-anti-account-sharing",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Prevents account sharing by enforcing a single active session policy for desktop devices.",
5
5
  "main": "library.js",
6
6
  "keywords": [
@@ -1,97 +1,238 @@
1
1
  'use strict';
2
2
 
3
- (function () {
4
- function showSecurityModal() {
5
- // Varsa eski modalları temizle
6
- $('#antishare-modal').remove();
7
-
8
- const modalHtml = `
9
- <div id="antishare-modal" style="
10
- position: fixed; top: 0; left: 0; width: 100%; height: 100%;
11
- background: rgba(0,0,0,0.92); z-index: 999999;
12
- display: flex; flex-direction: column; align-items: center; justify-content: center;
13
- text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
14
- animation: fadeIn 0.3s ease;
15
- ">
16
- <div style="
17
- background: #ffffff; padding: 40px; border-radius: 16px;
18
- max-width: 90%; width: 400px; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
19
- position: relative; overflow: hidden;
20
- ">
21
- <div style="position: absolute; top:0; left:0; width:100%; height:6px; background:#d9534f;"></div>
22
-
23
- <div style="
24
- width: 70px; height: 70px; background: #fdf2f2; border-radius: 50%;
25
- display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;
26
- ">
27
- <i class="fa fa-shield" style="font-size: 32px; color: #d9534f;"></i>
28
- </div>
29
-
30
- <h2 style="color: #333; margin: 0 0 10px; font-size: 22px; font-weight: 700;">Oturum Sonlandırıldı</h2>
31
-
32
- <p style="color: #666; font-size: 14px; line-height: 1.6; margin: 0 0 30px;">
33
- Hesabınıza <strong>başka bir bilgisayardan</strong> giriş yapıldığı tespit edildi.<br><br>
34
- Hesap güvenliğiniz için önceki cihazdaki oturum otomatik olarak kapatılmıştır.
35
- </p>
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
+ try {
31
+ const sids = await db.getSortedSetRange(SESS_SET(uid), 0, -1);
32
+ return Array.isArray(sids) ? sids : [];
33
+ } catch (e) {
34
+ return [];
56
35
  }
36
+ }
57
37
 
58
- function checkUrlParam() {
59
- const urlParams = new URLSearchParams(window.location.search);
60
- if (urlParams.get('error') === 'session-conflict') {
61
- showSecurityModal();
38
+ async function deleteSessionObjects(sid, logger) {
39
+ for (const k of sessionObjectKeys(sid)) {
40
+ try {
41
+ await db.delete(k);
42
+ logger.debug('Deleted session object', { key: k });
43
+ } catch (e) {
44
+ // ignore
62
45
  }
63
46
  }
47
+ }
64
48
 
65
- // --- 1) İlk yükleme + SPA geçişleri ---
66
- $(document).ready(checkUrlParam);
67
- $(window).on('action:ajaxify.end', checkUrlParam);
49
+ async function kickOtherSessions(uid, keepSid, logger) {
50
+ const all = await getUserSessionIds(uid);
51
+ if (!all.length) return { total: 0, removed: 0 };
68
52
 
69
- // --- 2) jQuery AJAX 401 yakala ---
70
- $(document).ajaxError(function (event, jqxhr) {
71
- if (jqxhr && jqxhr.status === 401 && jqxhr.responseJSON && jqxhr.responseJSON.error === 'session_terminated') {
72
- showSecurityModal();
53
+ let removed = 0;
54
+
55
+ for (const sid of all) {
56
+ if (!sid) continue;
57
+ if (keepSid && sid === keepSid) continue;
58
+
59
+ try {
60
+ await db.sortedSetRemove(SESS_SET(uid), sid);
61
+ removed += 1;
62
+ logger.info('Removed session from uid sessions set', { uid, sid });
63
+ } catch (e) {
64
+ logger.warn('Failed removing session from sessions set', { uid, sid, err: e.message });
73
65
  }
74
- });
75
66
 
76
- // --- 3) FETCH 401 yakala (asıl eksik buydu) ---
77
- const _fetch = window.fetch;
78
- window.fetch = async function (...args) {
79
- const res = await _fetch.apply(this, args);
67
+ await deleteSessionObjects(sid, logger);
68
+ }
69
+
70
+ return { total: all.length, removed };
71
+ }
80
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
+ const m = cookie.match(/connect\.sid=([^;]+)/i);
80
+ if (m && m[1]) return decodeURIComponent(m[1]).replace(/^s:/, '').split('.')[0];
81
+ return null;
82
+ }
83
+
84
+ async function shouldTrigger(uid, fp, nowSec, settings) {
85
+ const [lastfp, lastseen] = await Promise.all([
86
+ db.getObjectField(KEY_LAST_FP(uid), 'v'),
87
+ db.getObjectField(KEY_LAST_SEEN(uid), 'v'),
88
+ ]);
89
+
90
+ const lastSeenNum = parseInt(lastseen, 10);
91
+ const lastFpStr = lastfp ? String(lastfp) : '';
92
+
93
+ if (!lastFpStr) return { trigger: false, reason: 'no_last_fp' };
94
+ if (lastFpStr === fp) return { trigger: false, reason: 'same_fp' };
95
+
96
+ if (!Number.isFinite(lastSeenNum)) return { trigger: true, reason: 'fp_changed_no_lastseen' };
97
+
98
+ const diff = nowSec - lastSeenNum;
99
+ if (diff <= settings.windowSeconds) {
100
+ return { trigger: true, reason: `fp_changed_within_${settings.windowSeconds}s` };
101
+ }
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']) || '';
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 (res && res.status === 401) {
83
- // response body json olabilir; klonlayıp okuyalım
84
- const clone = res.clone();
85
- const data = await clone.json().catch(() => null);
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
+ const fp = getFingerprint(req, settings);
128
+ const nowSec = Math.floor(Date.now() / 1000);
86
129
 
87
- if (data && data.error === 'session_terminated') {
88
- showSecurityModal();
130
+ const check = await shouldTrigger(uid, fp, nowSec, settings);
131
+
132
+ if (check.trigger) {
133
+ const keepSid = getCurrentSessionId(req);
134
+
135
+ logger.warn('Account sharing suspected (fingerprint changed)', {
136
+ uid,
137
+ reason: check.reason,
138
+ keepSid: keepSid || '',
139
+ path: req.originalUrl || req.url,
140
+ });
141
+
142
+ if (settings.enforcement === 'block') {
143
+ await updateLast(uid, fp, req, nowSec);
144
+ return res.status(403).json({
145
+ error: 'account-sharing-detected',
146
+ message: 'Bu hesap farklı cihaz/ağ üzerinden aynı anda kullanılıyor olabilir.',
147
+ });
89
148
  }
149
+
150
+ // kick mode: diğer session’ları kır
151
+ const result = await kickOtherSessions(uid, keepSid, logger);
152
+ logger.info('Kick result', { uid, ...result });
90
153
  }
154
+
155
+ // her request sonunda last’i güncelle
156
+ await updateLast(uid, fp, req, nowSec);
157
+ return next();
91
158
  } catch (e) {
92
- // sessiz geç
159
+ logger.error('Middleware error', { err: e.message });
160
+ return next();
93
161
  }
94
-
95
- return res;
96
162
  };
97
- })();
163
+ }
164
+
165
+ const Security = {};
166
+
167
+ Security.init = async function (params) {
168
+ // NodeBB hook: static:app.load
169
+ const { app } = params;
170
+ const settings = await getSettings();
171
+ const logger = buildLogger(settings);
172
+
173
+ logger.info('Loading security middleware', {
174
+ enabled: settings.enabled,
175
+ enforcement: settings.enforcement,
176
+ windowSeconds: settings.windowSeconds,
177
+ trustProxy: settings.trustProxy,
178
+ });
179
+
180
+ if (!app) {
181
+ logger.warn('No express app passed to security.init (static:app.load?)');
182
+ return;
183
+ }
184
+
185
+ // Reverse proxy varsa gerçek IP için
186
+ try {
187
+ app.set('trust proxy', !!settings.trustProxy);
188
+ } catch (e) {}
189
+
190
+ // ✅ DEBUG ENDPOINT
191
+ // Sadece login olmuş kullanıcı görür (istersen admin-only yap)
192
+ app.get('/api/aas/debug', async (req, res) => {
193
+ try {
194
+ const uid = parseInt(req.uid, 10) || 0;
195
+ if (uid <= 0) {
196
+ return res.status(401).json({ ok: false, error: 'not-logged-in' });
197
+ }
198
+
199
+ // ✅ opsiyonel: sadece admin
200
+ // const isAdmin = await user.isAdministrator(uid);
201
+ // if (!isAdmin) return res.status(403).json({ ok: false, error: 'admin-only' });
202
+
203
+ const fp = getFingerprint(req, settings);
204
+ const sid = req.sessionID;
205
+
206
+ const sessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1);
207
+
208
+ logger.info('DEBUG endpoint hit', {
209
+ uid,
210
+ sid,
211
+ fp,
212
+ sessionsCount: sessions.length,
213
+ });
214
+
215
+ return res.json({
216
+ ok: true,
217
+ plugin: PLUGIN_ID,
218
+ uid,
219
+ sessionID: sid,
220
+ fingerprint: fp,
221
+ sessions,
222
+ headers: {
223
+ ua: req.headers['user-agent'],
224
+ ip: req.headers['x-forwarded-for'] || req.ip,
225
+ },
226
+ });
227
+ } catch (e) {
228
+ logger.error('DEBUG endpoint error', { err: e.message });
229
+ return res.status(500).json({ error: e.message });
230
+ }
231
+ });
232
+
233
+ // ✅ middleware attach
234
+ app.use(createSecurityMiddleware(settings, logger));
235
+ logger.info('Security middleware attached', { plugin: PLUGIN_ID });
236
+ };
237
+
238
+ module.exports = Security;