added on local at 2026-07-02 15:46:18
| 1 | package 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 | ||
| 25 | use strict; | |
| 26 | use warnings; | |
| 27 | ||
| 28 | use MODS::DBConnect; | |
| 29 | use MODS::RePricer::Config; | |
| 30 | ||
| 31 | # Optional deps — code paths fall back gracefully if missing. | |
| 32 | my $HAS_ARGON2 = eval { require Crypt::Argon2; 1 }; | |
| 33 | my $HAS_UUID = eval { require Data::UUID; 1 }; | |
| 34 | my $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 | ||
| 39 | my $db = MODS::DBConnect->new; | |
| 40 | my $config = MODS::RePricer::Config->new; | |
| 41 | my $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). | |
| 50 | our $CURRENT_USER_TZ; | |
| 51 | ||
| 52 | sub 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 | #---------------------------------------------------------------------- | |
| 63 | sub 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 | #---------------------------------------------------------------------- | |
| 220 | sub 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 | #---------------------------------------------------------------------- | |
| 315 | sub 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 | #---------------------------------------------------------------------- | |
| 365 | sub 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 | #---------------------------------------------------------------------- | |
| 400 | sub 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 | #---------------------------------------------------------------------- | |
| 417 | sub 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 | ||
| 451 | sub 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 | ||
| 463 | sub _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 | ||
| 473 | sub _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. | |
| 492 | sub _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 | ||
| 514 | sub _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. | |
| 535 | sub _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. | |
| 545 | sub _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. | |
| 559 | sub _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? | |
| 602 | sub _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 | ||
| 624 | 1; |