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.
Files changed (3) hide show
  1. package/library.js +106 -67
  2. package/package.json +1 -1
  3. package/static/security.js +188 -79
package/library.js CHANGED
@@ -1,91 +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
- }
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
- Plugin.init = async function () {
17
- console.log(`[Anti-Share] Aktif (Limit: ${MAX_DEVICES} PC)`);
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
- // 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;
21
+ // Log seviyesi: "debug" | "info" | "warn" | "error"
22
+ logLevel: 'info',
24
23
 
25
- if (!req || !uid || !req.sessionID) return;
26
- if (isMobile(req)) return;
24
+ // Fingerprint: ip + user-agent (+ accept-language opsiyonel)
25
+ includeAcceptLanguage: false,
27
26
 
28
- const key = `antishare:sessionsz:${uid}`; // <-- DİKKAT: yeni key (zset)
29
- const sid = req.sessionID;
27
+ // Reverse-proxy arkasında doğru ip için
28
+ trustProxy: true,
29
+ };
30
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
- }
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
- // 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);
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
- // Her page + api isteğinde kontrol
46
- Plugin.checkSession = async function (data) {
47
- const req = data?.req;
48
- const res = data?.res;
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 (!req || !res) return data;
51
- if (!req.uid || req.uid <= 0 || isMobile(req)) 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;
52
64
 
53
- const isAdmin = await user.isAdministrator(req.uid);
54
- if (isAdmin) return data;
65
+ return out;
66
+ }
55
67
 
56
- const sid = req.sessionID;
57
- if (!sid) return data;
68
+ function sha1(input) {
69
+ return crypto.createHash('sha1').update(String(input)).digest('hex');
70
+ }
58
71
 
59
- const key = `antishare:sessionsz:${req.uid}`;
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
- // Sadece en yeni MAX_DEVICES session allowed
62
- const allowed = await db.getSortedSetRevRange(key, 0, MAX_DEVICES - 1);
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
- // Eğer henüz kayıt yoksa (çok nadir) karışma
65
- if (!Array.isArray(allowed) || !allowed.length) return data;
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
- // 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
- }
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
- const wantsJson =
75
- req.xhr ||
76
- (req.headers.accept && req.headers.accept.includes('json')) ||
77
- 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
+ }
78
109
 
79
- if (wantsJson) {
80
- res.status(401).json({ error: 'session_terminated', message: 'Maksimum cihaz sınırına ulaşıldı.' });
81
- return;
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
- res.redirect('/login?error=session-conflict');
85
- return;
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 = Plugin;
122
+ module.exports = {
123
+ PLUGIN_ID,
124
+ DEFAULTS,
125
+ getSettings,
126
+ getFingerprint,
127
+ getClientIp,
128
+ sha1,
129
+ buildLogger,
130
+ };
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.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;