Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/MODS/Login.pm
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/MODS/Login.pm

added on local at 2026-07-02 15:46:18

Added
+624
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 56d2be1bd438
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::Login;
2#======================================================================
3# RePricer — login / sessions
4#
5# Cookie-based auth backed by the `users` and `user_sessions` tables
6# in db_schema.sql. On login we mint a UUID, drop a row in
7# user_sessions, and set repricer_session=<uuid> on the response.
8# On every page load we look up the cookie, verify expiry/revocation,
9# and return a userinfo hash joined from users + the session row.
10#
11# Password hashing strategy (best available wins):
12# 1. Crypt::Argon2 (industry best, requires a C compiler)
13# 2. Digest::SHA + pbkdf2_hex with sha256, 120k iterations
14# — Digest::SHA is core in Perl since 5.9.3, so this always works
15# 3. None of the above → signup/login disabled
16#
17# Session tokens: Data::UUID if installed, else a built-in RFC4122
18# v4 generator. No CPAN install required for either path.
19#
20# CPAN dependencies
21# REQUIRED : DBI, DBD::mysql, CGI
22# OPTIONAL : Crypt::Argon2 (better passwords), Data::UUID (faster)
23#======================================================================
24
25use strict;
26use warnings;
27
28use MODS::DBConnect;
29use MODS::RePricer::Config;
30
31# Optional deps — code paths fall back gracefully if missing.
32my $HAS_ARGON2 = eval { require Crypt::Argon2; 1 };
33my $HAS_UUID = eval { require Data::UUID; 1 };
34my $HAS_DIGEST = eval { require Digest::SHA; 1 };
35# Older Digest::SHA (pre-5.65) doesn't export pbkdf2_hex. We don't use
36# it — see _hash_iter below — but we still need sha256_hex which has
37# been in Digest::SHA since 2003.
38
39my $db = MODS::DBConnect->new;
40my $config = MODS::RePricer::Config->new;
41my $DB = $config->settings('database_name');
42
43# Cached IANA timezone for the viewer on this request. Populated by
44# login_verify() when a session lookup succeeds; read by
45# MODS::DBConnect::db_connect() to SET time_zone on every fresh dbh so
46# NOW() / CURDATE() / DATE() / HOUR() and every TIMESTAMP column read
47# returns values in the viewer's local time across every reporting
48# page. Empty until login_verify runs (the initial lookup itself runs
49# in MariaDB's default tz -- harmless, no timezone-sensitive reads).
50our $CURRENT_USER_TZ;
51
52sub new {
53 my ($class) = @_;
54 return bless({}, $class);
55}
56
57#----------------------------------------------------------------------
58# login_verify() — call at the top of every authenticated .cgi.
59# Reads the session cookie, looks the user up, returns:
60# undef — not logged in (caller should redirect to login)
61# $userinfo — hashref with user + session columns
62#----------------------------------------------------------------------
63sub login_verify {
64 my ($self) = @_;
65
66 my $cookie_name = $config->settings('cookie_name');
67 my $token = _read_cookie($cookie_name);
68 return undef unless $token && length($token) >= 16;
69 return undef if $token =~ /[^a-zA-Z0-9\-]/; # CHAR(36) UUID, hex+dash only
70
71 my $dbh = $db->db_connect();
72 return undef unless $dbh;
73
74 # owner_user_id / permission_groups_id are nullable columns added in
75 # the team-members migration. Use IFNULL so the lookup tolerates the
76 # pre-migration state (columns absent on local dev DBs is caught by
77 # the schema-probe in team.cgi -- here we just defensively coerce).
78 my $sql = qq~
79 SELECT
80 u.id AS user_id,
81 u.email,
82 u.display_name,
83 u.plan_tier,
84 u.account_status,
85 u.is_admin,
86 u.owner_user_id,
87 u.permission_groups_id,
88 u.two_factor_enabled,
89 u.default_currency,
90 u.timezone,
91 u.last_active_at,
92 u.session_minutes_override,
93 u.account_state,
94 s.id AS session_id,
95 s.expires_at,
96 s.revoked_at
97 FROM ${DB}.users u
98 JOIN ${DB}.user_sessions s ON s.user_id = u.id
99 WHERE s.id = '$token'
100 AND s.revoked_at IS NULL
101 AND s.expires_at > NOW()
102 AND s.two_factor_pending = 0
103 AND u.account_status = 'active'
104 LIMIT 1
105 ~;
106 my $row = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
107
108 if (!$row || !$row->{user_id}) {
109 $db->db_disconnect($dbh);
110 return undef;
111 }
112
113 # Impersonation hook. If the user is an admin AND has an active row
114 # in admin_impersonation for this session, swap the returned userinfo
115 # to the target user. The admin's own identity is preserved in
116 # _admin_* keys so the wrapper can render the "Acting as ..." banner
117 # and require_admin checks can still pass while impersonating.
118 if ($row->{is_admin}) {
119 my $imp_check = $db->db_readwrite($dbh, qq~
120 SELECT COUNT(*) AS n FROM information_schema.tables
121 WHERE table_schema='$DB' AND table_name='admin_impersonation'
122 ~, $ENV{SCRIPT_NAME}, __LINE__);
123
124 if ($imp_check && $imp_check->{n}) {
125 my $sid = $row->{session_id};
126 $sid =~ s/[^a-zA-Z0-9\-]//g;
127 my $imp = $db->db_readwrite($dbh, qq~
128 SELECT i.target_user_id, i.mode,
129 t.email AS t_email,
130 t.display_name AS t_display_name,
131 t.plan_tier AS t_plan_tier,
132 t.account_status AS t_account_status,
133 t.default_currency AS t_default_currency,
134 t.timezone AS t_timezone
135 FROM ${DB}.admin_impersonation i
136 JOIN ${DB}.users t ON t.id = i.target_user_id
137 WHERE i.session_id = '$sid'
138 LIMIT 1
139 ~, $ENV{SCRIPT_NAME}, __LINE__);
140
141 if ($imp && $imp->{target_user_id}) {
142 $row->{_admin_user_id} = $row->{user_id};
143 $row->{_admin_email} = $row->{email};
144 $row->{_admin_display_name} = $row->{display_name};
145 $row->{_admin_is_admin} = 1;
146 $row->{_admin_mode} = $imp->{mode} || 'control';
147 $row->{_impersonating} = 1;
148
149 $row->{user_id} = $imp->{target_user_id};
150 $row->{email} = $imp->{t_email};
151 $row->{display_name} = $imp->{t_display_name};
152 $row->{plan_tier} = $imp->{t_plan_tier};
153 $row->{account_status} = $imp->{t_account_status};
154 $row->{default_currency} = $imp->{t_default_currency};
155 $row->{timezone} = $imp->{t_timezone};
156 $row->{is_admin} = 0; # target user's role, not the admin's
157 }
158 }
159 }
160
161 # === Sliding-session idle check + maintenance lockdown ===
162 # Real session expiry is computed here, not at session-mint time.
163 # Idle timeout = settings('session_minutes') (or per-user override).
164 # If exceeded, delete the session row and treat as logged out.
165 my $_idle = $row->{session_minutes_override} ? int($row->{session_minutes_override}) : int($config->settings('session_minutes') || 720);
166 if ($row->{last_active_at}) {
167 my $_exp = $db->db_readwrite($dbh, qq{
168 SELECT (TIMESTAMPDIFF(MINUTE, '$row->{last_active_at}', NOW()) > $_idle) AS expired
169 }, $ENV{SCRIPT_NAME}, __LINE__);
170 if ($_exp && $_exp->{expired}) {
171 my $_sid = $row->{session_id}; $_sid =~ s/[^a-zA-Z0-9\-]//g;
172 $db->db_readwrite($dbh, qq{DELETE FROM ${DB}.user_sessions WHERE id='$_sid'}, $ENV{SCRIPT_NAME}, __LINE__);
173 $db->db_disconnect($dbh);
174 return undef;
175 }
176 }
177 # Maintenance lockdown: only admins pass when locked. Impersonation
178 # bookkeeping uses _admin_is_admin to preserve the admin's role here.
179 if ($config->settings('maintenance_locked') && !$row->{is_admin} && !$row->{_admin_is_admin}) {
180 my $_sid = $row->{session_id}; $_sid =~ s/[^a-zA-Z0-9\-]//g;
181 $db->db_readwrite($dbh, qq{DELETE FROM ${DB}.user_sessions WHERE id='$_sid'}, $ENV{SCRIPT_NAME}, __LINE__);
182 $db->db_disconnect($dbh);
183 return undef;
184 }
185 # Account state guard: cancelled / banned / billing_locked users
186 # get their session killed here mid-flow. Admins impersonating a
187 # locked user still see the account (for support / audit).
188 if ($row->{account_state} && $row->{account_state} ne 'active' && !$row->{_admin_is_admin}) {
189 my $_sid = $row->{session_id}; $_sid =~ s/[^a-zA-Z0-9\-]//g;
190 $db->db_readwrite($dbh, qq{DELETE FROM ${DB}.user_sessions WHERE id='$_sid'}, $ENV{SCRIPT_NAME}, __LINE__);
191 $db->db_disconnect($dbh);
192 return undef;
193 }
194 # Bump last_active_at for the REAL user (preserves through impersonation)
195 my $_real_uid = $row->{_admin_user_id} || $row->{user_id};
196 $_real_uid =~ s/[^0-9]//g;
197 $db->db_readwrite($dbh, qq{UPDATE ${DB}.users SET last_active_at = NOW() WHERE id = $_real_uid}, $ENV{SCRIPT_NAME}, __LINE__);
198
199 # Phase-3 auto-add IP to tracking_exclusions if the toggle is on.
200 _phase3_autoadd_tracking_ip($db, $dbh, $DB, $config, $row);
201
202 $db->db_disconnect($dbh);
203
204 # Cache the viewer's timezone so every subsequent $db->db_connect()
205 # on this request applies SET time_zone automatically. Impersonation
206 # already swapped $row->{timezone} to the target user's value above,
207 # so an admin browsing AS a seller sees the seller's local time --
208 # which is the right behavior because they're inspecting that
209 # seller's reports.
210 $CURRENT_USER_TZ = $row->{timezone} || 'UTC';
211
212 return $row;
213}
214
215#----------------------------------------------------------------------
216# login_exec($email, $password) — verify creds, mint session.
217# Returns ($userinfo, $session_token) on success, () on failure.
218# Caller is responsible for sending Set-Cookie before any other output.
219#----------------------------------------------------------------------
220sub login_exec {
221 my ($self, $email, $password) = @_;
222 return () unless $email && $password;
223
224 my $dbh = $db->db_connect();
225 return () unless $dbh;
226
227 my $safe_email = $email;
228 $safe_email =~ s/[\\']/\\$&/g;
229
230 my $sql = qq~
231 SELECT id, email, password_hash, account_status, account_state, two_factor_enabled
232 FROM ${DB}.users
233 WHERE email = '$safe_email'
234 LIMIT 1
235 ~;
236 my $user = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
237
238 if (!$user || !$user->{id} || $user->{account_status} ne 'active') {
239 $db->db_disconnect($dbh);
240 return ();
241 }
242 if ($user->{account_state} && $user->{account_state} ne 'active') {
243 $db->db_disconnect($dbh);
244 return ();
245 }
246 if (_phase3_is_blocked($db, $dbh, $DB, $ENV{REMOTE_ADDR}, $email)) {
247 $db->db_disconnect($dbh);
248 return ();
249 }
250
251 if (!_verify_password($password, $user->{password_hash})) {
252 $db->db_disconnect($dbh);
253 return ();
254 }
255
256 # Mint session.
257 # expires_at is a 10-year safety net only -- real expiry is the
258 # sliding idle check in login_verify against users.last_active_at.
259 my $token = _new_uuid();
260 my $minutes = 5256000; # ~10 years
261 my $ua = ($ENV{HTTP_USER_AGENT} || '');
262 $ua =~ s/[\\']/\\$&/g;
263 $ua = substr($ua, 0, 255);
264
265 # Pack the client IP in Perl rather than relying on INET6_ATON / INET_ATON,
266 # which differ across MariaDB versions (INET6_ATON only exists in 10.0+).
267 # ip_address is VARBINARY(16) so we hex-encode and use UNHEX(), or NULL if
268 # we can't parse it.
269 my $ip_clause = 'NULL';
270 if (my $ip = $ENV{REMOTE_ADDR}) {
271 my $packed;
272 if (eval { require Socket; 1 } && defined &Socket::inet_pton) {
273 $packed = eval { Socket::inet_pton(Socket::AF_INET(), $ip) }
274 || eval { Socket::inet_pton(Socket::AF_INET6(), $ip) };
275 }
276 if ($packed) {
277 $ip_clause = "UNHEX('" . unpack('H*', $packed) . "')";
278 }
279 }
280
281 # When 2FA is enabled, mint a pending session. login_verify rejects
282 # rows with two_factor_pending=1; only /two_factor_verify.cgi can
283 # flip the flag (after the user's TOTP code validates).
284 my $pending = $user->{two_factor_enabled} ? 1 : 0;
285 my $sql_ins = qq~
286 INSERT INTO ${DB}.user_sessions SET
287 id = '$token',
288 user_id = '$user->{id}',
289 ip_address = $ip_clause,
290 user_agent = '$ua',
291 issued_at = NOW(),
292 expires_at = DATE_ADD(NOW(), INTERVAL $minutes MINUTE),
293 two_factor_pending = $pending
294 ~;
295 $db->db_readwrite($dbh, $sql_ins, $ENV{SCRIPT_NAME}, __LINE__);
296
297 my $sql_login = qq~UPDATE ${DB}.users SET last_login_at = NOW(), last_active_at = NOW() WHERE id = '$user->{id}'~;
298 $db->db_readwrite($dbh, $sql_login, $ENV{SCRIPT_NAME}, __LINE__);
299
300 # Re-read the full row so the wrapper has display_name, plan_tier, etc.
301 my $sql_full = qq~SELECT * FROM ${DB}.users WHERE id = '$user->{id}'~;
302 my $userinfo = $db->db_readwrite($dbh, $sql_full, $ENV{SCRIPT_NAME}, __LINE__);
303 $userinfo->{user_id} = $userinfo->{id};
304 $userinfo->{session_id} = $token;
305
306 $db->db_disconnect($dbh);
307
308 return ($userinfo, $token);
309}
310
311#----------------------------------------------------------------------
312# signup($email, $password, $display_name, $plan_tier) — create a
313# user row. Returns user_id on success, undef on dup-email / failure.
314#----------------------------------------------------------------------
315sub signup {
316 my ($self, $email, $password, $display_name, $plan_tier) = @_;
317 return undef unless $email && $password && length($password) >= 8;
318
319 $plan_tier ||= 'pro';
320 # Plan v3: only pro/business/scale are valid ENUM values. Anything
321 # else (caller passing legacy free/starter/studio) gets coerced to
322 # the default Pro plan so the INSERT doesn't silently truncate to ''.
323 my %tier_ok = map { $_ => 1 } qw(pro business scale);
324 $plan_tier = 'pro' unless $tier_ok{$plan_tier};
325 $display_name ||= (split /\@/, $email)[0];
326
327 my $hash = _hash_password($password);
328 return undef unless $hash;
329
330 my $dbh = $db->db_connect();
331 return undef unless $dbh;
332
333 # --- phase4-signup-blocklist :: 2026-07-02 ---
334 if (_phase3_is_blocked($db, $dbh, $DB, $ENV{REMOTE_ADDR}, $email)) {
335 $db->db_disconnect($dbh);
336 return undef;
337 }
338
339 for ($email, $hash, $display_name, $plan_tier) { s/[\\']/\\$&/g; }
340
341 my $sql = qq~
342 INSERT INTO ${DB}.users SET
343 email = '$email',
344 password_hash = '$hash',
345 display_name = '$display_name',
346 plan_tier = '$plan_tier',
347 account_status = 'active',
348 created_at = NOW()
349 ~;
350 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
351 $db->db_disconnect($dbh);
352
353 return $r->{mysql_insertid};
354}
355
356#----------------------------------------------------------------------
357# update_password($user_id, $new_plain) — replace a user's password
358# hash. Used by /reset_password.cgi (after token verification) and by
359# the admin "force-reset" action. Revokes all active sessions for the
360# user as a side effect -- a password change should log the attacker
361# out of any session they might already hold.
362#
363# Returns 1 on success, 0 on failure.
364#----------------------------------------------------------------------
365sub update_password {
366 my ($self, $user_id, $new_plain) = @_;
367 $user_id =~ s/[^0-9]//g if defined $user_id;
368 return 0 unless $user_id;
369 return 0 unless defined $new_plain && length($new_plain) >= 8;
370
371 my $hash = _hash_password($new_plain);
372 return 0 unless $hash;
373 $hash =~ s/[\\']/\\$&/g;
374
375 my $dbh = $db->db_connect();
376 return 0 unless $dbh;
377
378 $db->db_readwrite($dbh, qq~
379 UPDATE ${DB}.users
380 SET password_hash='$hash'
381 WHERE id='$user_id'
382 ~, $ENV{SCRIPT_NAME}, __LINE__);
383
384 # Revoke every active session for this user. Forces a re-login on
385 # any device the legitimate owner had open, but also kicks an
386 # attacker out the moment we rotate the credential.
387 $db->db_readwrite($dbh, qq~
388 UPDATE ${DB}.user_sessions
389 SET revoked_at=NOW()
390 WHERE user_id='$user_id' AND revoked_at IS NULL
391 ~, $ENV{SCRIPT_NAME}, __LINE__);
392
393 $db->db_disconnect($dbh);
394 return 1;
395}
396
397#----------------------------------------------------------------------
398# logout_exec($userinfo_or_token) — revoke the session.
399#----------------------------------------------------------------------
400sub logout_exec {
401 my ($self, $userinfo) = @_;
402 my $token = ref($userinfo) ? $userinfo->{session_id} : $userinfo;
403 return unless $token;
404 return if $token =~ /[^a-zA-Z0-9\-]/;
405
406 my $dbh = $db->db_connect();
407 return unless $dbh;
408 my $sql = qq~UPDATE ${DB}.user_sessions SET revoked_at = NOW() WHERE id = '$token'~;
409 $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
410 $db->db_disconnect($dbh);
411 return 1;
412}
413
414#----------------------------------------------------------------------
415# Cookie header helpers — print these BEFORE any Content-Type / body.
416#----------------------------------------------------------------------
417sub set_cookie_header {
418 my ($self, $token) = @_;
419 my $name = $config->settings('cookie_name');
420 my $minutes = 5256000; # 10y; server controls real expiry
421 # session_minutes_override: lift cookie Max-Age to match the server-side
422 # expires_at when the owning user has a per-account override.
423 if ($token) {
424 my $cdbh = $db->db_connect();
425 if ($cdbh) {
426 my $tk = $token; $tk =~ s/'/''/g;
427 my $ovr = $db->db_readwrite($cdbh, qq~
428 SELECT u.session_minutes_override
429 FROM ${DB}.user_sessions s
430 JOIN ${DB}.users u ON u.id = s.user_id
431 WHERE s.id = '$tk' LIMIT 1
432 ~, __FILE__, __LINE__);
433 if ($ovr && $ovr->{session_minutes_override}) {
434 $minutes = int($ovr->{session_minutes_override});
435 }
436 $db->db_disconnect($cdbh);
437 }
438 }
439 my $secure = $config->settings('secure_cookies') ? '; Secure' : '';
440 # Domain= (leading dot) shares the session across every subdomain
441 # of the platform so a logged-in seller is recognized on their own
442 # storefront subdomain (3dshawn.repricer.com) -- not just on the
443 # dashboard host (repricer.com). Empty string falls back to the
444 # legacy host-only behavior.
445 my $dom_v = $config->settings('cookie_domain') || '';
446 my $domain = length($dom_v) ? "; Domain=$dom_v" : '';
447 return "Set-Cookie: $name=$token; Path=/$domain; Max-Age=" . ($minutes * 60) .
448 "; HttpOnly; SameSite=Lax$secure\n";
449}
450
451sub clear_cookie_header {
452 my ($self) = @_;
453 my $name = $config->settings('cookie_name');
454 my $dom_v = $config->settings('cookie_domain') || '';
455 my $domain = length($dom_v) ? "; Domain=$dom_v" : '';
456 return "Set-Cookie: $name=; Path=/$domain; Max-Age=0; HttpOnly; SameSite=Lax\n";
457}
458
459#======================================================================
460# Internal helpers
461#======================================================================
462
463sub _read_cookie {
464 my ($name) = @_;
465 my $raw = $ENV{HTTP_COOKIE} || '';
466 foreach my $piece (split /;\s*/, $raw) {
467 my ($k, $v) = split /=/, $piece, 2;
468 return $v if defined $k && $k eq $name;
469 }
470 return undef;
471}
472
473sub _new_uuid {
474 if ($HAS_UUID) {
475 return lc(Data::UUID->new->create_str);
476 }
477 # RFC4122 v4 (random) — 8-4-4-4-12 lowercase hex.
478 my @hex = ('0'..'9','a'..'f');
479 my $rand = sub { join('', map { $hex[int(rand 16)] } 1..$_[0]) };
480 return $rand->(8) . '-' . $rand->(4) . '-4' . $rand->(3) . '-' .
481 ((qw(8 9 a b))[int(rand 4)]) . $rand->(3) . '-' . $rand->(12);
482}
483
484# Stored format strings:
485# argon2id → "$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>"
486# iter-sha256 → "iter-sha256$<iter>$<salt>$<hash-hex>" (our own format)
487#
488# The iter-sha256 format is salted, iterated SHA-256. It isn't RFC-blessed
489# PBKDF2 but provides equivalent key-stretching protection at 100k rounds
490# and works on any Perl with Digest::SHA — including the very old version
491# this server ships, which pre-dates Digest::SHA's pbkdf2_hex export.
492sub _hash_password {
493 my ($plain) = @_;
494 return undef unless defined $plain && length $plain;
495
496 if ($HAS_ARGON2) {
497 my $salt = '';
498 $salt .= chr(int(rand(256))) for 1..16;
499 return Crypt::Argon2::argon2id_pass($plain, $salt, 3, '32M', 1, 32);
500 }
501
502 if ($HAS_DIGEST) {
503 my @c = ('a'..'z','A'..'Z','0'..'9');
504 my $salt = '';
505 $salt .= $c[int(rand 62)] for 1..16;
506 my $iter = 100_000;
507 my $hex = _iter_sha256($plain, $salt, $iter);
508 return "iter-sha256\$$iter\$$salt\$$hex";
509 }
510
511 return undef;
512}
513
514sub _verify_password {
515 my ($plain, $stored) = @_;
516 return 0 unless defined $plain && defined $stored;
517
518 if ($stored =~ /^\$argon2/ && $HAS_ARGON2) {
519 return eval { Crypt::Argon2::argon2id_verify($stored, $plain) } ? 1 : 0;
520 }
521
522 if ($stored =~ /^iter-sha256\$(\d+)\$([^\$]+)\$([0-9a-f]+)$/ && $HAS_DIGEST) {
523 my ($iter, $salt, $hex) = ($1, $2, $3);
524 my $computed = _iter_sha256($plain, $salt, $iter);
525 return _ct_eq($computed, $hex);
526 }
527
528 return 0;
529}
530
531# Salted iterated SHA-256. Each round hashes (salt . previous-hash). The
532# salt is mixed into every iteration so a precomputed rainbow table for
533# unsalted SHA-256 is useless. 100k rounds = ~200ms per verify, which
534# rate-limits brute-force attempts naturally.
535sub _iter_sha256 {
536 my ($password, $salt, $iter) = @_;
537 my $hash = $password;
538 for (1 .. $iter) {
539 $hash = Digest::SHA::sha256_hex($salt . $hash);
540 }
541 return $hash;
542}
543
544# Constant-time string compare to avoid timing oracles on hash verify.
545sub _ct_eq {
546 my ($a, $b) = @_;
547 return 0 unless defined $a && defined $b;
548 return 0 if length($a) != length($b);
549 my $diff = 0;
550 for my $i (0 .. length($a) - 1) {
551 $diff |= ord(substr($a, $i, 1)) ^ ord(substr($b, $i, 1));
552 }
553 return $diff == 0 ? 1 : 0;
554}
555
556# --- phase3-account-state-blocklist-guard :: 2026-07-02 ---
557
558# Phase-3 helper: auto-add caller's IP to tracking_exclusions.
559sub _phase3_autoadd_tracking_ip {
560 my ($db, $dbh, $DB, $config, $row) = @_;
561 my $ip = $ENV{REMOTE_ADDR};
562 return unless $ip;
563 $ip =~ s/[^0-9a-fA-F:.]//g;
564 return unless length $ip;
565
566 my $is_admin_real = $row->{is_admin} || $row->{_admin_is_admin};
567 my $real_uid = $row->{_admin_user_id} || $row->{user_id};
568 $real_uid =~ s/[^0-9]//g if defined $real_uid;
569
570 if ($is_admin_real && $config->settings('auto_add_login_ip_admin')) {
571 # Check-then-insert (INSERT IGNORE won't dedupe -- no unique key
572 # possible on (scope, ip, user_id) because user_id is NULL for
573 # admin scope and MariaDB treats NULLs as distinct in unique keys).
574 my $exists = $db->db_readwrite($dbh, qq{
575 SELECT id FROM ${DB}.tracking_exclusions
576 WHERE scope='admin' AND ip='$ip' AND user_id IS NULL LIMIT 1
577 }, $ENV{SCRIPT_NAME}, __LINE__);
578 if (!$exists || !$exists->{id}) {
579 $db->db_readwrite($dbh, qq{
580 INSERT INTO ${DB}.tracking_exclusions
581 (scope, user_id, ip, label, source, active)
582 VALUES ('admin', NULL, '$ip', 'auto-added on login', 'auto_login', 1)
583 }, $ENV{SCRIPT_NAME}, __LINE__);
584 }
585 }
586 elsif (!$is_admin_real && $real_uid && $config->settings('auto_add_login_ip_user')) {
587 my $exists = $db->db_readwrite($dbh, qq{
588 SELECT id FROM ${DB}.tracking_exclusions
589 WHERE scope='user' AND ip='$ip' AND user_id=$real_uid LIMIT 1
590 }, $ENV{SCRIPT_NAME}, __LINE__);
591 if (!$exists || !$exists->{id}) {
592 $db->db_readwrite($dbh, qq{
593 INSERT INTO ${DB}.tracking_exclusions
594 (scope, user_id, ip, label, source, active)
595 VALUES ('user', $real_uid, '$ip', 'auto-added on login', 'auto_login', 1)
596 }, $ENV{SCRIPT_NAME}, __LINE__);
597 }
598 }
599}
600
601# Phase-3 helper: is this IP/email currently on the blocklist?
602sub _phase3_is_blocked {
603 my ($db, $dbh, $DB, $ip, $email) = @_;
604 my $ip_q = defined $ip ? $ip : '';
605 my $email_q = defined $email ? lc($email) : '';
606 $ip_q =~ s/[^0-9a-fA-F:.]//g;
607 $email_q =~ s/[\\']/\\$&/g;
608 my $domain = '';
609 if ($email_q =~ /\@(.+)$/) { $domain = $1; }
610 my $sql = qq{
611 SELECT COUNT(*) AS n FROM ${DB}.blocklist
612 WHERE active=1
613 AND (
614 (kind='ip' AND value='$ip_q') OR
615 (kind='email' AND value='$email_q') OR
616 (kind='email_domain' AND value='$domain')
617 )
618 LIMIT 1
619 };
620 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
621 return ($r && $r->{n}) ? 1 : 0;
622}
623
6241;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help