nodebb-plugin-anti-account-sharing 1.0.3 → 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.
Files changed (3) hide show
  1. package/library.js +104 -78
  2. package/package.json +1 -1
  3. package/static/security.js +188 -79
package/library.js CHANGED
@@ -1,104 +1,130 @@
1
1
  'use strict';
2
2
 
3
- const db = require.main.require('./src/database');
4
- const user = require.main.require('./src/user');
3
+ const crypto = require('crypto');
4
+ const winston = require.main.require('winston');
5
+ const meta = require.main.require('./src/meta');
5
6
 
6
- const Plugin = {};
7
+ const PLUGIN_ID = 'nodebb-plugin-anti-account-sharing';
7
8
 
8
- // --- AYARLAR ---
9
- const MAX_DEVICES = 1; // kaç bilgisayar?
9
+ const DEFAULTS = {
10
+ enabled: true,
10
11
 
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)`);
18
- };
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',
19
16
 
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;
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,
24
20
 
25
- if (!req || !uid || !req.sessionID) return;
26
- if (isMobile(req)) return;
21
+ // Log seviyesi: "debug" | "info" | "warn" | "error"
22
+ logLevel: 'info',
27
23
 
28
- const key = `antishare:sessionsz:${uid}`; // <-- DİKKAT: yeni key (zset)
29
- const sid = req.sessionID;
30
-
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
+ // Fingerprint: ip + user-agent (+ accept-language opsiyonel)
25
+ includeAcceptLanguage: false,
36
26
 
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);
42
- }
27
+ // Reverse-proxy arkasında doğru ip için
28
+ trustProxy: true,
43
29
  };
44
30
 
45
- // Her page + api isteğinde kontrol
46
- Plugin.checkSession = async function (data) {
47
- const req = data?.req;
48
- const res = data?.res;
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
+ }
49
36
 
50
- if (!req || !res) return data;
51
- if (!req.uid || req.uid <= 0 || isMobile(req)) return data;
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 = {};
50
+ }
52
51
 
53
- const isAdmin = await user.isAdministrator(req.uid);
54
- if (isAdmin) return data;
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
+ };
55
60
 
56
- const sid = req.sessionID;
57
- if (!sid) return data;
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;
58
64
 
59
- const key = `antishare:sessionsz:${req.uid}`; // <-- yeni key
65
+ return out;
66
+ }
60
67
 
61
- // allowed = en yeni MAX_DEVICES session
62
- let newest = await db.getSortedSetRevRange(key, 0, -1);
68
+ function sha1(input) {
69
+ return crypto.createHash('sha1').update(String(input)).digest('hex');
70
+ }
63
71
 
64
- // Fallback: eğer hiç kayıt yoksa veya bu sid listede yoksa (ilk defa görüyorsak) ekle
65
- // (AMA mevcut sid varsa score'u güncelleme! yoksa eski session kendini "yeniler" ve kick yemez)
66
- if (!newest.includes(sid)) {
67
- await db.sortedSetAdd(key, Date.now(), sid);
68
- newest = await db.getSortedSetRevRange(key, 0, -1);
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();
69
77
  }
78
+ return (
79
+ req.ip ||
80
+ (req.connection && req.connection.remoteAddress) ||
81
+ (req.socket && req.socket.remoteAddress) ||
82
+ ''
83
+ );
84
+ }
70
85
 
71
- // Trim (fazla varsa sil)
72
- const toRemove = newest.slice(MAX_DEVICES);
73
- if (toRemove.length) {
74
- await db.sortedSetRemove(key, toRemove);
75
- newest = newest.slice(0, MAX_DEVICES);
76
- }
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
+ }
77
93
 
78
- const allowed = newest; // en yeni MAX_DEVICES
94
+ function buildLogger(settings) {
95
+ // winston ile NodeBB loglarına düşer, ayrıca console’a da basar.
96
+ const level = settings?.logLevel || 'info';
79
97
 
80
- // Bu session allowed değilse -> kick
81
- if (!allowed.includes(sid)) {
82
- try { req.logout?.(); } catch (e) {}
83
- if (req.session) {
84
- try { req.session.destroy(() => {}); } catch (e) {}
85
- }
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);
86
103
 
87
- const wantsJson =
88
- req.xhr ||
89
- (req.headers.accept && req.headers.accept.includes('json')) ||
90
- req.originalUrl?.startsWith('/api/');
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
+ }
91
109
 
92
- if (wantsJson) {
93
- res.status(401).json({ error: 'session_terminated', message: 'Maksimum cihaz sınırına ulaşıldı.' });
94
- return;
95
- }
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
+ }
96
117
 
97
- res.redirect('/login?error=session-conflict');
98
- return;
99
- }
118
+ function safeJson(x) {
119
+ try { return JSON.stringify(x); } catch (e) { return '[unjsonable]'; }
120
+ }
100
121
 
101
- return data;
122
+ module.exports = {
123
+ PLUGIN_ID,
124
+ DEFAULTS,
125
+ getSettings,
126
+ getFingerprint,
127
+ getClientIp,
128
+ sha1,
129
+ buildLogger,
102
130
  };
103
-
104
- module.exports = Plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-anti-account-sharing",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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,206 @@
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
+ // 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
- function checkUrlParam() {
59
- const urlParams = new URLSearchParams(window.location.search);
60
- if (urlParams.get('error') === 'session-conflict') {
61
- showSecurityModal();
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
- // --- 1) İlk yükleme + SPA geçişleri ---
66
- $(document).ready(checkUrlParam);
67
- $(window).on('action:ajaxify.end', checkUrlParam);
55
+ let removed = 0;
56
+ for (const sid of all) {
57
+ if (!sid) continue;
58
+ if (keepSid && sid === keepSid) continue;
68
59
 
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();
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
- // --- 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);
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 (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
+ // 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
- if (data && data.error === 'session_terminated') {
88
- showSecurityModal();
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
- // sessiz geç
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;