2026-10-18 19:27:17 -04:00
<? php
/** Shared account storage and security rules; no HTML or request dispatch here. */
class AuthService extends Base {
public const IDLE_TIMEOUT = 1800 ;
2026-10-21 06:53:45 -04:00
public const ABSOLUTE_TIMEOUT = 28800 ;
2026-10-18 19:27:17 -04:00
public function findUser ( string $identifier ) : ? array {
$stmt = $this -> connect () -> prepare ( 'SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1' );
$stmt -> execute ([ ':email' => trim ( $identifier ), ':name' => trim ( $identifier )]);
return $stmt -> fetch () ?: null ;
}
public static function passwordError ( string $password , string $confirmation ) : string {
if ( $password !== $confirmation )
return 'Passwords must match.' ;
// PASSWORD_DEFAULT is bcrypt on supported PHP versions; avoid silent truncation.
if ( strlen ( $password ) < 12 || strlen ( $password ) > 72 || str_contains ( $password , " \0 " )) {
return 'Use a password between 12 and 72 bytes long.' ;
}
return '' ;
}
/** Shared database counters survive session/cookie changes. Requires the auth migration. */
public function allowAttempt ( string $scope , string $identifier , int $limit , int $seconds ) : bool {
$db = $this -> connect ();
$key = hash ( 'sha256' , $scope . ':' . strtolower ( trim ( $identifier )));
$stmt = $db -> prepare ( 'INSERT INTO auth_attempts (attempt_key, attempts, window_started) VALUES (:key, 1, UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE attempts = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :seconds SECOND), 1, attempts + 1),
window_started = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :window SECOND), UTC_TIMESTAMP(), window_started)' );
$stmt -> execute ([ ':key' => $key , ':seconds' => $seconds , ':window' => $seconds ]);
$stmt = $db -> prepare ( 'SELECT attempts FROM auth_attempts WHERE attempt_key = :key' );
$stmt -> execute ([ ':key' => $key ]);
return ( int ) $stmt -> fetchColumn () <= $limit ;
}
public function login ( string $identifier , string $password ) : bool {
$ipAllowed = $this -> allowAttempt ( 'login-ip' , $_SERVER [ 'REMOTE_ADDR' ] ?? '' , 50 , 900 );
$accountAllowed = $this -> allowAttempt ( 'login-account' , $identifier , 10 , 900 );
if ( ! $ipAllowed || ! $accountAllowed )
return false ;
$user = $this -> findUser ( $identifier );
// A real hash avoids an immediate return for unknown accounts.
$hash = $user [ 'user_password_hash' ] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.' ;
$verified = password_verify ( $password , $hash );
if ( ! $user || ! $verified || $user [ 'user_status' ] !== 'active' )
return false ;
if ( password_needs_rehash ( $hash , PASSWORD_DEFAULT )) {
$stmt = $this -> connect () -> prepare ( 'UPDATE users SET user_password_hash = :hash WHERE user_serial = :id AND user_password_hash = :old_hash' );
$stmt -> execute ([ ':hash' => password_hash ( $password , PASSWORD_DEFAULT ), ':id' => $user [ 'user_serial' ], ':old_hash' => $hash ]);
}
session_regenerate_id ( true );
2026-10-21 06:53:45 -04:00
unset ( $_SESSION [ 'mfa_user' ], $_SESSION [ 'recent_auth' ], $_SESSION [ 'mfa_pending' ], $_SESSION [ 'mfa_replacement' ]);
$_SESSION [ 'authenticated_at' ] = time ();
2026-10-18 19:27:17 -04:00
$_SESSION [ self :: USER_AUTHENTICATED ] = true ;
$_SESSION [ self :: USER_SERIAL ] = ( int ) $user [ 'user_serial' ];
$_SESSION [ self :: USER_NAME ] = $user [ 'user_name' ];
$_SESSION [ self :: USER_FULL_NAME ] = $user [ 'user_full_name' ];
$_SESSION [ self :: USER_EMAIL ] = $user [ 'user_email' ];
$_SESSION [ self :: USER_ROLE ] = $user [ 'user_role' ];
$_SESSION [ 'auth_version' ] = ( int ) $user [ 'user_auth_version' ];
$_SESSION [ 'last_activity' ] = time ();
$_SESSION [ 'csrf_token' ] = bin2hex ( random_bytes ( 32 ));
$stmt = $this -> connect () -> prepare ( 'UPDATE users SET user_last_login = :now WHERE user_serial = :id' );
$stmt -> execute ([ ':now' => $this -> getTimestamp (), ':id' => $user [ 'user_serial' ]]);
2026-10-20 06:45:30 -04:00
Audit :: record ( $this -> connect (),( int ) $user [ 'user_serial' ], 'logged in (' . $user [ 'user_role' ] . ')' );
2026-10-18 19:27:17 -04:00
return true ;
}
public function currentUser () : ? array {
if ( empty ( $_SESSION [ self :: USER_AUTHENTICATED ]))
return null ;
2026-10-21 06:53:45 -04:00
if ( time () - ( $_SESSION [ 'last_activity' ] ?? 0 ) > self :: IDLE_TIMEOUT || time () - ( $_SESSION [ 'authenticated_at' ] ?? 0 ) >= self :: ABSOLUTE_TIMEOUT )
2026-10-18 19:27:17 -04:00
return null ;
$stmt = $this -> connect () -> prepare ( 'SELECT * FROM view_users WHERE user_serial = :id' );
$stmt -> execute ([ ':id' => $_SESSION [ self :: USER_SERIAL ] ?? 0 ]);
$user = $stmt -> fetch ();
if ( ! $user || $user [ 'user_status' ] !== 'active' || ( int ) $user [ 'user_auth_version' ] !== ( $_SESSION [ 'auth_version' ] ?? - 1 ))
return null ;
$_SESSION [ 'last_activity' ] = time ();
$_SESSION [ self :: USER_ROLE ] = $user [ 'user_role' ];
$_SESSION [ self :: USER_NAME ] = $user [ 'user_name' ];
$_SESSION [ self :: USER_FULL_NAME ] = $user [ 'user_full_name' ];
$_SESSION [ self :: USER_EMAIL ] = $user [ 'user_email' ];
return $user ;
}
public function register ( array $input ) : void {
$first = trim ( $input [ 'user_first_name' ] ?? '' );
$last = trim ( $input [ 'user_last_name' ] ?? '' );
$email = trim ( $input [ 'user_email' ] ?? '' );
$name = trim ( $input [ 'user_name' ] ?? '' );
if ( ! $first || ! $last || strlen ( $first ) > 128 || strlen ( $last ) > 128 )
throw new InvalidArgumentException ( 'Enter your first and last name (up to 128 bytes each).' );
if ( ! filter_var ( $email , FILTER_VALIDATE_EMAIL ) || strlen ( $email ) > 100 )
throw new InvalidArgumentException ( 'Enter a valid email address (up to 100 bytes).' );
if ( ! preg_match ( '/\A[A-Za-z0-9_.-]{3,64}\z/' , $name ))
2026-10-20 06:45:30 -04:00
throw new InvalidArgumentException ( 'Use 3 to 64 letters, numbers, dots, underscores or hyphens for your username.' );
2026-10-18 19:27:17 -04:00
$error = self :: passwordError ( $input [ 'user_password' ] ?? '' , $input [ 'user_password_confirm' ] ?? '' );
if ( $error )
throw new InvalidArgumentException ( $error );
$db = $this -> connect ();
// Cross-field uniqueness keeps email-or-username login unambiguous.
$stmt = $db -> prepare ( 'SELECT user_serial FROM users WHERE user_name IN (:name, :email) OR user_email IN (:email2, :name2) LIMIT 1' );
$stmt -> execute ([ ':name' => $name , ':email' => $email , ':email2' => $email , ':name2' => $name ]);
if ( $stmt -> fetch ())
throw new InvalidArgumentException ( 'Unable to create an account with these details. Try signing in or resetting your password.' );
$stmt = $db -> prepare ( "INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')" );
2026-10-21 06:53:45 -04:00
$kind = $input [ 'registration_kind' ] ?? 'account' ;
if ( ! in_array ( $kind ,[ 'account' , 'new_club' , 'invited' ], true )) throw new InvalidArgumentException ( 'Choose a valid signup option.' );
$clubName = trim ( $input [ 'club_name' ] ?? '' );
if ( $kind === 'new_club' && ( $clubName === '' || strlen ( $clubName ) > 128 )) throw new InvalidArgumentException ( 'Enter your new club name (up to 128 bytes).' );
2026-10-18 19:27:17 -04:00
try {
2026-10-21 06:53:45 -04:00
DatabaseTransaction :: run ( $db , function ( PDO $db ) use ( $stmt , $name , $first , $last , $email , $input , $kind , $clubName ){
if ( $kind === 'invited' ){
$invites = $db -> prepare ( "SELECT i.club_serial,i.invited_by,i.membership_role FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial WHERE i.invite_email=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active'" ); $invites -> execute ([ $email ]); $valid = false ; $access = new TenantAccess ();
foreach ( $invites -> fetchAll () as $invite ) if ( $access -> canManage (( int ) $invite [ 'club_serial' ], $db ,( int ) $invite [ 'invited_by' ]) && ( $invite [ 'membership_role' ] === 'member' || $access -> isSuper ( $db ,( int ) $invite [ 'invited_by' ]))){ $valid = true ; break ;}
if ( ! $valid ) throw new InvalidArgumentException ( 'Ask your club administrator for a current invitation to this email address.' );
}
$stmt -> execute ([ ':name' => $name , ':first' => $first , ':last' => $last , ':email' => $email , ':hash' => password_hash ( $input [ 'user_password' ], PASSWORD_DEFAULT )]);
$user = ( int ) $db -> lastInsertId ();
if ( $kind === 'new_club' ){
$club = $db -> prepare ( "INSERT INTO clubs(club_name,club_status) VALUES(?,'active')" ); $club -> execute ([ $clubName ]); $clubId = ( int ) $db -> lastInsertId ();
$membership = $db -> prepare ( "INSERT INTO club_memberships(club_serial,user_serial,membership_role,membership_status,joined_on) VALUES(?,?,'administrator','active',?)" ); $membership -> execute ([ $clubId , $user , date ( 'Y-m-d' )]);
Audit :: required ( $db , $user , 'registered new club ' . json_encode ([ 'club' => $clubId , 'name' => $clubName ]));
}
});
2026-10-18 19:27:17 -04:00
} catch ( PDOException $e ) {
if ( $e -> getCode () === '23000' )
throw new InvalidArgumentException ( 'Unable to create an account with these details. Try signing in or resetting your password.' );
throw $e ;
}
}
/** Never modify the password when somebody merely requests an email. */
2026-10-20 06:45:30 -04:00
public function requestReset ( string $identifier ) : bool {
2026-10-18 19:27:17 -04:00
$ipAllowed = $this -> allowAttempt ( 'reset-ip' , $_SERVER [ 'REMOTE_ADDR' ] ?? '' , 10 , 3600 );
$accountAllowed = $this -> allowAttempt ( 'reset-account' , $identifier , 3 , 3600 );
if ( ! $ipAllowed || ! $accountAllowed )
2026-10-20 06:45:30 -04:00
return false ;
2026-10-18 19:27:17 -04:00
$user = $this -> findUser ( $identifier );
if ( ! $user || $user [ 'user_status' ] !== 'active' )
2026-10-20 06:45:30 -04:00
return false ;
2026-10-18 19:27:17 -04:00
$baseUrl = rtrim (( string ) $this -> getSettings () -> ApplicationUrl , '/' );
if ( ! filter_var ( $baseUrl , FILTER_VALIDATE_URL ) || parse_url ( $baseUrl , PHP_URL_SCHEME ) !== 'https'
|| parse_url ( $baseUrl , PHP_URL_QUERY ) !== null || parse_url ( $baseUrl , PHP_URL_FRAGMENT ) !== null
|| parse_url ( $baseUrl , PHP_URL_USER ) !== null || parse_url ( $baseUrl , PHP_URL_PASS ) !== null ) {
throw new RuntimeException ( 'Configure an HTTPS ApplicationUrl before sending reset emails.' );
}
$token = bin2hex ( random_bytes ( 32 ));
$db = $this -> connect ();
$hash = hash ( 'sha256' , $token );
2026-10-20 06:45:30 -04:00
$db -> beginTransaction ();
try {
// Share the user-row lock with account edits and password changes. A
// request resolved before such a change must not mint a token afterward.
$stmt = $db -> prepare ( 'SELECT user_email,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE' );
$stmt -> execute ([ $user [ 'user_serial' ]]); $current = $stmt -> fetch ();
if ( ! $current || $current [ 'user_status' ] !== 'active' || $current [ 'user_email' ] !== $user [ 'user_email' ] || ( int ) $current [ 'user_auth_version' ] !== ( int ) $user [ 'user_auth_version' ]){
$db -> rollBack (); return false ;
}
$stmt = $db -> prepare ( 'INSERT INTO password_resets (token_hash,user_serial,expires_at) VALUES (?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 30 MINUTE))' );
$stmt -> execute ([ $hash , $user [ 'user_serial' ]]); $db -> commit ();
} catch ( Throwable $e ){ if ( $db -> inTransaction ()) $db -> rollBack (); throw $e ;}
2026-10-18 19:27:17 -04:00
$url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode ( $token );
if ( ! $this -> deliverResetEmail ( $user , $url )) {
$stmt = $db -> prepare ( 'DELETE FROM password_resets WHERE token_hash = :hash' );
$stmt -> execute ([ ':hash' => $hash ]);
throw new RuntimeException ( 'Password reset email delivery failed.' );
}
2026-10-20 06:45:30 -04:00
Audit :: record ( $db ,( int ) $user [ 'user_serial' ], 'requested password reset' );
return true ;
2026-10-18 19:27:17 -04:00
}
protected function deliverResetEmail ( array $user , string $url ) : bool {
return ( new Email ()) -> sendPasswordReset ( $user , $url );
}
public function resetPassword ( string $token , string $password , string $confirmation ) : bool {
$error = self :: passwordError ( $password , $confirmation );
if ( $error )
throw new InvalidArgumentException ( $error );
if ( ! preg_match ( '/\A[a-f0-9]{64}\z/' , $token ))
return false ;
$db = $this -> connect ();
$db -> beginTransaction ();
try {
// Serialize resets and password changes on the user row.
$stmt = $db -> prepare ( 'SELECT user_serial FROM password_resets WHERE token_hash = :hash' );
$stmt -> execute ([ ':hash' => hash ( 'sha256' , $token )]);
$id = $stmt -> fetchColumn ();
if ( ! $id ) {
$db -> rollBack ();
return false ;
}
$stmt = $db -> prepare ( "SELECT user_serial FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE" );
$stmt -> execute ([ ':id' => $id ]);
if ( ! $stmt -> fetch ()) {
$db -> rollBack ();
return false ;
}
$stmt = $db -> prepare ( 'SELECT token_hash FROM password_resets WHERE token_hash = :hash AND expires_at > UTC_TIMESTAMP() FOR UPDATE' );
$stmt -> execute ([ ':hash' => hash ( 'sha256' , $token )]);
if ( ! $stmt -> fetch ()) {
$db -> rollBack ();
return false ;
}
$this -> storePassword ( $db , ( int ) $id , $password );
2026-10-20 06:45:30 -04:00
$stmt = $db -> prepare ( 'UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?' ); $stmt -> execute ([ $id ]);
2026-10-18 19:27:17 -04:00
$db -> commit ();
2026-10-20 06:45:30 -04:00
Audit :: record ( $db ,( int ) $id , 'reset password' );
2026-10-18 19:27:17 -04:00
return true ;
} catch ( Throwable $e ) {
if ( $db -> inTransaction ())
$db -> rollBack ();
throw $e ;
}
}
public function changePassword ( int $id , string $current , string $password , string $confirmation ) : bool {
$error = self :: passwordError ( $password , $confirmation );
if ( $error )
throw new InvalidArgumentException ( $error );
$db = $this -> connect ();
$db -> beginTransaction ();
try {
$stmt = $db -> prepare ( "SELECT user_password_hash, user_auth_version FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE" );
$stmt -> execute ([ ':id' => $id ]);
$user = $stmt -> fetch ();
if ( ! $user || ( int ) $user [ 'user_auth_version' ] !== ( $_SESSION [ 'auth_version' ] ?? - 1 ) || ! password_verify ( $current , $user [ 'user_password_hash' ])) {
$db -> rollBack ();
return false ;
}
$this -> storePassword ( $db , $id , $password );
$db -> commit ();
2026-10-20 06:45:30 -04:00
Audit :: record ( $db , $id , 'changed password' );
2026-10-18 19:27:17 -04:00
return true ;
} catch ( Throwable $e ) {
if ( $db -> inTransaction ())
$db -> rollBack ();
throw $e ;
}
}
private function storePassword ( PDO $db , int $id , string $password ) : void {
$stmt = $db -> prepare ( 'UPDATE users SET user_password_hash = :hash, user_change_password = 0, user_auth_version = user_auth_version + 1 WHERE user_serial = :id' );
$stmt -> execute ([ ':hash' => password_hash ( $password , PASSWORD_DEFAULT ), ':id' => $id ]);
$stmt = $db -> prepare ( 'DELETE FROM password_resets WHERE user_serial = :id' );
$stmt -> execute ([ ':id' => $id ]);
}
}