Split authentication & newsletter subscription
Deploy Spot / deploy (push) Successful in 32s

This commit is contained in:
2026-08-10 16:41:20 +02:00
parent 3e3c3ee821
commit 462502cf9f
19 changed files with 628 additions and 481 deletions
+10 -5
View File
@@ -12,6 +12,8 @@ class Controller extends PhpObject
'add_post',
'subscribe',
'unsubscribe',
'login',
'logout',
'update_project',
'upload',
'add_comment',
@@ -55,11 +57,15 @@ class Controller extends PhpObject
$this->setReqVal('value', $asReq['value'] ?? '');
$this->setReqVal('type', $asReq['type'] ?? '');
$this->setReqVal('email', $asReq['email'] ?? '');
$this->setReqVal('password', $asReq['password'] ?? '');
$this->setReqVal('latitude', $asReq['latitude'] ?? '');
$this->setReqVal('longitude', $asReq['longitude'] ?? '');
$this->setReqVal('timestamp', $asReq['timestamp'] ?? 0, 'positiveInt');
$this->setReqVal('csrf_token', $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? ''));
//Authentication and CSRF protection share the same server-side session.
$this->initCsrfToken();
//Create Spot Instance
$this->oSpot = new Spot($sProcessPage, $this->asReq['t']);
$this->oSpot->setProjectId($this->asReq['id_project']);
@@ -72,6 +78,7 @@ class Controller extends PhpObject
//Clean errors
$sDebug = ob_get_clean();
if($sDebug != '') $this->oSpot->addUncaughtError($sDebug);
if(session_status() === PHP_SESSION_ACTIVE) session_write_close();
return $sResult;
}
@@ -103,16 +110,13 @@ class Controller extends PhpObject
{
if(PHP_SAPI === 'cli') return;
$bCloseSession = false;
if(session_status() !== PHP_SESSION_ACTIVE) {
$bSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
session_set_cookie_params(array('httponly' => true, 'secure' => $bSecure, 'samesite' => 'Lax'));
session_start();
$bCloseSession = true;
}
$this->setCsrfToken();
if($bCloseSession) session_write_close();
}
private function checkCsrfToken(string $sClientToken): bool
@@ -130,9 +134,10 @@ class Controller extends PhpObject
'next_feed' => $this->oSpot->getNextFeed($this->asReq['id']),
'new_feed' => $this->oSpot->getNewFeed($this->asReq['id']),
'add_post' => $this->oSpot->addPost($this->asReq['name'], $this->asReq['content']),
'subscribe' => $this->oSpot->subscribe($this->asReq['email'], $this->asReq['name']),
'subscribe' => $this->oSpot->subscribe(),
'unsubscribe' => $this->oSpot->unsubscribe(),
'unsubscribe_email' => $this->oSpot->unsubscribeFromEmail($this->asReq['id_entity']),
'login' => $this->oSpot->login($this->asReq['email'], $this->asReq['password'], $this->asReq['name']),
'logout' => $this->oSpot->logout(),
'update_project' => $this->oSpot->updateProject(),
default => $this->dispatchAdmin($sAction)
};
+25 -28
View File
@@ -76,7 +76,7 @@ class Spot extends Main
'email' => 'admin@admin.com',
'language' => self::DEFAULT_LANG,
'timezone' => date_default_timezone_get(),
'active' => User::USER_ACTIVE,
'subscribed'=> User::USER_SUBSCRIBED,
'clearance' => User::CLEARANCE_ADMIN
));
$this->oUser->setUserId($iUserId);
@@ -94,13 +94,12 @@ class Spot extends Main
Project::PROJ_TABLE => array('name', 'codename', 'active_from', 'active_to'),
self::POST_TABLE => array(Db::getId(Project::PROJ_TABLE), Db::getId(User::USER_TABLE), 'name', 'content', 'site_time', 'timezone'),
Media::MEDIA_TABLE => array(Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'comment'),
User::USER_TABLE => array('name', 'email', 'gravatar', 'language', 'timezone', 'active', 'clearance'),
User::USER_TABLE => array('name', 'email', 'password', 'token', 'token_exp', 'gravatar', 'language', 'timezone', 'subscribed', 'clearance'),
Map::MAP_TABLE => array('codename', 'pattern', 'token', 'tile_size', 'min_zoom', 'max_zoom', 'attribution'),
Map::MAPPING_TABLE => array(Db::getId(Map::MAP_TABLE) , Db::getId(Project::PROJ_TABLE))
),
'types' => array
(
'active' => "BOOLEAN DEFAULT ".User::USER_INACTIVE,
'clearance' => "TINYINT(1) DEFAULT ".User::CLEARANCE_USER,
'active_from' => "TIMESTAMP DEFAULT 0",
'active_to' => "TIMESTAMP DEFAULT 0",
@@ -120,6 +119,7 @@ class Spot extends Main
'model' => "VARCHAR(20)",
'name' => "VARCHAR(100)",
'pattern' => "VARCHAR(200) NOT NULL",
'password' => "VARCHAR(255) NOT NULL DEFAULT ''",
'posted_on' => "TIMESTAMP DEFAULT 0",
'ref_feed_id' => "VARCHAR(40)",
'ref_msg_id' => "VARCHAR(15)",
@@ -127,9 +127,11 @@ class Spot extends Main
'rotate' => "SMALLINT",
'site_time' => "TIMESTAMP DEFAULT 0", //DEFAULT 0 removes auto-set to current time
'status' => "VARCHAR(10)",
'subscribed' => "BOOLEAN DEFAULT ".User::USER_UNSUBSCRIBED,
'taken_on' => "TIMESTAMP DEFAULT 0",
'timezone' => "CHAR(64) NOT NULL", //see mysql.time_zone_name
'token' => "VARCHAR(4096)",
'token_exp' => "TIMESTAMP DEFAULT 0",
'type' => "VARCHAR(20)",
'unix_time' => "INT",
'min_zoom' => "TINYINT UNSIGNED",
@@ -282,7 +284,7 @@ class Spot extends Main
private function sendEmail() {
$oEmail = new Email($this->asContext['serv_name'], 'email.update');
$oEmail->setDestInfo($this->oUser->getActiveUsersInfo());
$oEmail->setDestInfo($this->oUser->getSubscribedUsersInfo());
//Add Position
$asSpotMessages = $this->getSpotMessages(array($this->oProject->getLastMessageId($this->getFeedConstraints(Feed::MSG_TABLE))));
@@ -377,32 +379,32 @@ class Spot extends Main
return self::getJsonResult(true, '', $asLastUpdate);
}
public function subscribe($sEmail, $sNickName) {
$asResult = $this->oUser->addUser($sEmail, $this->oLang->getLanguage(), date_default_timezone_get(), $sNickName);
$asUserInfo = $this->oUser->getUserInfo();
public function login($sEmail, $sPassword, $sNickName) {
$asResult = $this->oUser->login($sEmail, $sPassword, $this->oLang->getLanguage(), date_default_timezone_get(), $sNickName);
//Send Confirmation Email
if($asResult['result'] && $asResult['desc']=='lang:newsletter.subscribed' && !Settings::DEBUG) {
if($asResult['result'] && $asResult['desc'] == 'subscribe_user') return $this->subscribe();
else return self::getJsonResult($asResult['result'], $asResult['desc'], $this->oUser->getUserInfo());
}
public function logout() {
$asResult = $this->oUser->logout();
return self::getJsonResult($asResult['result'], $asResult['desc'], User::DEFAULT_USER);
}
public function subscribe() {
$asResult = $this->oUser->setSubscription(true);
$asUserInfo = $this->oUser->getUserInfo();
if($asResult['result'] && !Settings::DEBUG) {
$oConfEmail = new Email($this->asContext['serv_name'], 'email.confirmation');
$oConfEmail->setDestInfo($asUserInfo);
$oConfEmail->send();
}
return self::getJsonResult($asResult['result'], $asResult['desc'], $asUserInfo);
}
public function unsubscribe() {
$asResult = $this->oUser->removeUser();
return self::getJsonResult($asResult['result'], $asResult['desc'], User::DEFAULT_USER);
}
public function unsubscribeFromEmail($iUserId) {
$this->oUser->setUserId($iUserId);
$this->oLang->setLanguage($this->oUser->getLang(), self::DEFAULT_LANG);
$asResult = $this->oUser->removeUser();
$sDesc = explode(':', $asResult['desc'])[1];
return $this->oLang->getTranslation($sDesc);
$asResult = $this->oUser->setSubscription(false);
return self::getJsonResult($asResult['result'], $asResult['desc'], $this->oUser->getUserInfo());
}
private function getSpotMessages($asMsgIds=array())
@@ -726,7 +728,7 @@ class Spot extends Main
'project' => $this->oProject->getProjects(),
'feed' => $oFeed->getFeeds(),
'spot' => $oFeed->getSpots(),
'user' => $this->oUser->getActiveUsersInfo()
'user' => $this->oUser->getSubscribedUsersInfo()
);
foreach($asData['project'] as &$asProject) {
@@ -794,7 +796,7 @@ class Spot extends Main
default:
$sDesc = $this->oLang->getTranslation('error.unknown_field', $sField);
}
$asResult = $this->oUser->getActiveUserInfo($iId);
$asResult = $this->oUser->getUserById($iId);
break;
}
if(!$bSuccess && $sDesc=='') $sDesc = Mask::LANG_PREFIX.'error.commit_db';
@@ -852,11 +854,6 @@ class Spot extends Main
$sDesc = $asResult['feed'][0]['desc'];
$bSuccess = $asResult['feed'][0]['del'];
break;
case 'user':
$asResult = array('user' => array($this->oUser->removeUser($iId)));
$sDesc = $asResult['user'][0]['desc'];
$bSuccess = $asResult['user'][0]['result'];
break;
}
return self::getJsonResult($bSuccess, $sDesc, $asResult);
+198 -74
View File
@@ -11,14 +11,17 @@ class User extends PhpObject {
const USER_TABLE = 'users';
//Clearance Levels
const USER_ACTIVE = 1;
const USER_INACTIVE = 0;
const CLEARANCE_USER = 0;
const CLEARANCE_ADMIN = 9;
const CLEARANCES = array('user'=>self::CLEARANCE_USER, 'admin'=>self::CLEARANCE_ADMIN);
//Cookie
const COOKIE_ID_USER = 'subscriber';
const USER_SUBSCRIBED = 1;
const USER_UNSUBSCRIBED = 0;
//Session & Cookie
const SESSION_ID_USER = 'id_user';
const SESSION_ADMIN = 'admin_authenticated';
const COOKIE_TOKEN = 'login';
const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
const DEFAULT_USER = array(
@@ -28,7 +31,7 @@ class User extends PhpObject {
'email' => '',
'language' => '',
'timezone' => '',
'active' => self::USER_INACTIVE,
'subscribed'=> self::USER_UNSUBSCRIBED,
'clearance' => self::CLEARANCE_USER
);
@@ -46,8 +49,7 @@ class User extends PhpObject {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setUserId(0);
$this->asUserInfo = self::DEFAULT_USER;
$this->checkUserCookie();
$this->checkSession();
}
public function getUserId() {
@@ -56,9 +58,10 @@ class User extends PhpObject {
public function setUserId($iUserId) {
$this->iUserId = 0;
$this->asUserInfo = self::DEFAULT_USER;
if($iUserId > 0) {
$asUser = $this->getActiveUserInfo($iUserId);
$asUser = $this->getUserById($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = $asUser;
@@ -70,27 +73,23 @@ class User extends PhpObject {
return $this->asUserInfo;
}
public function getActiveUserInfo($iUserId) {
public function getUserById($iUserId) {
$asUsersInfo = array();
if($iUserId > 0) $asUsersInfo = $this->getActiveUsersInfo($iUserId);
if($iUserId > 0) $asUsersInfo = $this->getUsersInfo($iUserId);
return empty($asUsersInfo)?array():array_shift($asUsersInfo);
}
public function getActiveUsersInfo($iUserId=-1) {
public function getUsersInfo($iUserId=-1) {
//Mapping between user fields and DB fields
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE)." AS id";
//Non-admin cannot access clearance info
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) unset($asSelect['clearance']);
$asInfo = array(
'select' => $asSelect,
'from' => self::USER_TABLE,
'constraint'=> array('active'=>self::USER_ACTIVE)
'from' => self::USER_TABLE
);
if($iUserId != -1) $asInfo['constraint'][Db::getId(self::USER_TABLE)] = $iUserId;
if($iUserId != -1) $asInfo['constraint'] = array(Db::getId(self::USER_TABLE) => $iUserId);
return $this->oDb->selectRows($asInfo);
}
@@ -99,71 +98,116 @@ class User extends PhpObject {
return $this->asUserInfo['language'];
}
public function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
private function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$sDesc = '';
$sEmail = trim($sEmail);
//Check Email availability
$iUserId = $this->oDb->selectValue(self::USER_TABLE, Db::getId(self::USER_TABLE), array('email'=>$sEmail, 'active'=>self::USER_ACTIVE));
$iUserId = $this->oDb->insertRow(
self::USER_TABLE,
array('email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone)
);
if($iUserId > 0) {
//Just log user in
$sDesc = 'lang:newsletter.email_exists';
$bSuccess = true;
if($iUserId == 0) $sDesc = 'lang:error.commit_db';
else $bSuccess = true;
//Extra optional values
if($bSuccess) {
$this->updateNickname($sNickName);
$this->updateGravatar($iUserId, $sEmail);
}
return Spot::getResult($bSuccess, $sDesc, [Db::getId(self::USER_TABLE) => $iUserId]);
}
public function setSubscription($bSubscribed) {
if($this->getUserId() > 0) {
$iSubscribed = $bSubscribed?1:0;
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), array('subscribed'=>$iSubscribed));
if(!$iUserId) return Spot::getResult(false, 'lang:error.commit_db');
$this->asUserInfo['subscribed'] = $iSubscribed;
return Spot::getResult(true, $iSubscribed?'lang:account.subscribed':'lang:account.unsubscribed');
}
}
public function getSubscribedUsersInfo() {
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
return $this->oDb->selectRows(array(
'select'=>$asSelect,
'from'=>self::USER_TABLE,
'constraint'=>array('subscribed'=>self::USER_SUBSCRIBED)
));
}
public function login($sEmail, $sPassword, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$sDesc = '';
$sEmail = strtolower(trim($sEmail));
//Check email value
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) {
$sDesc = 'lang:account.invalid_email';
}
else {
//Add/Reactivate user
$iUserId = $this->oDb->insertUpdateRow(
//Check Email presence in DB
$asDBUser = $this->oDb->selectRow(
self::USER_TABLE,
array('email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone, 'active'=>self::USER_ACTIVE),
array('email')
array('email' => $sEmail),
array(Db::getId(self::USER_TABLE), 'password', 'clearance')
);
$iUserId = $asDBUser[Db::getId(self::USER_TABLE)] ?? 0;
//User exists
if($iUserId > 0) {
//Is Admin
if($asDBUser['clearance'] >= self::CLEARANCE_ADMIN) {
//Request a password
if($sPassword === '') $sDesc = empty($asDBUser['password'])?'lang:account.set_password':'lang:account.password_required';
//Set password
elseif(empty($asDBUser['password'])) {
if(!$this->oDb->updateRow(self::USER_TABLE, $iUserId, array('password' => password_hash($sPassword, PASSWORD_DEFAULT)))) $sDesc = 'lang:error.commit_db';
else {
$sDesc = 'lang:account.password_set';
$bSuccess = true;
}
}
if($iUserId==0) $sDesc = 'lang:error.commit_db';
else {
$sDesc = 'lang:newsletter.subscribed';
$bSuccess = true;
//Check password
elseif(password_verify($sPassword, $asDBUser['password'])) {
$bSuccess = true;
$sDesc = 'lang:account.logged_in';
}
else $sDesc = 'lang:account.invalid_credentials';
//die(print_r(['provided_pass'=>$sPassword, 'db_pass'=>$asDBUser['password']], true));
}
else $bSuccess = true;
}
else {
//Unknown user, create it
$asAddResult = $this->addUser($sEmail, $sLang, $sTimezone, $sNickName);
$bSuccess = $asAddResult['result'];
$sDesc = $bSuccess?'subscribe_user':$asAddResult['desc'];
$iUserId = $asAddResult['data'][Db::getId(self::USER_TABLE)] ?? 0;
}
}
if($bSuccess) {
$this->setUserId($iUserId);
//Set Cookie (valid 1 year)
$this->updateCookie(self::COOKIE_DURATION);
//Update Nickname if user has already posted
$this->updateNickname($sNickName);
//Retrieve Gravatar image
$this->updateGravatar($iUserId, $sEmail);
$this->setSession();
$this->setTokenCookie();
}
return Spot::getResult($bSuccess, $sDesc);
}
public function removeUser($iUserId=0) {
$iUserId = ($iUserId > 0)?$iUserId:$this->getUserId();
$bSelf = ($iUserId == $this->getUserId());
$bSuccess = false;
$sDesc = '';
if($bSelf || $this->checkUserClearance(self::CLEARANCE_ADMIN)) {
if($this->getUserId() > 0) {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, array('active' => self::USER_INACTIVE));
if($iUserId==0) $sDesc = 'lang:error.commit_db';
else {
$sDesc = 'lang:newsletter.unsubscribed';
if($bSelf) $this->updateCookie(-60 * 60); //Set Cookie in the past, deleting it
$bSuccess = true;
}
}
else $sDesc = 'lang:newsletter.unknown_email';
}
else $sDesc = 'lang:error.no_auth';
return Spot::getResult($bSuccess, $sDesc);
public function logout() {
if($this->getUserId() > 0) $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), array('token' => '', 'token_exp' => '0000-00-00 00:00:00'));
$this->clearSession();
$this->clearCookie();
$this->setUserId(0);
return Spot::getResult(true, 'lang:account.logged_out');
}
public function updateNickname($sNickname) {
@@ -175,15 +219,6 @@ class User extends PhpObject {
$this->oDb->updateRow(self::USER_TABLE, $iUserId, array('gravatar' => base64_encode($sImage)));
}
private function checkUserCookie() {
if(isset($_COOKIE[self::COOKIE_ID_USER])){
$this->setUserId($_COOKIE[self::COOKIE_ID_USER]);
//Extend cookie life
if($this->getUserId() > 0) $this->updateCookie(self::COOKIE_DURATION);
}
}
public function checkUserClearance($iClearance)
{
return ($this->asUserInfo['clearance'] >= $iClearance);
@@ -206,7 +241,96 @@ class User extends PhpObject {
return Spot::getResult($bSuccess, $sDesc);
}
private function updateCookie($iDeltaTime) {
setcookie(self::COOKIE_ID_USER, ($iDeltaTime < 0)?'':$this->getUserId(), array('samesite' => 'Lax', 'expires' => time() + $iDeltaTime));
/* Session */
private function checkSession() {
$iUserId = (int) ($_SESSION[self::SESSION_ID_USER] ?? 0);
if($iUserId > 0) {
$this->setUserId($iUserId);
if($this->checkUserClearance(self::CLEARANCE_ADMIN) && empty($_SESSION[self::SESSION_ADMIN])) $this->logout();
}
else $this->checkTokenCookie();
}
private function setSession() {
session_regenerate_id(true);
$_SESSION[self::SESSION_ID_USER] = $this->getUserId();
$_SESSION[self::SESSION_ADMIN] = $this->checkUserClearance(self::CLEARANCE_ADMIN);
}
private function clearSession() {
unset($_SESSION[self::SESSION_ID_USER], $_SESSION[self::SESSION_ADMIN]);
}
/* Cookie */
private function checkTokenCookie() {
$sCookieValue = $this->getCookie();
//Check cookie structure
$asToken = explode(':', $sCookieValue, 3);
$iUserId = $asToken[0];
if(count($asToken) !== 3 || !ctype_digit($iUserId) || !ctype_digit($asToken[1]) || !preg_match('/^[a-f0-9]{64}$/', $asToken[2])) {
if($sCookieValue !== '') $this->clearCookie();
return;
}
//Get user info
$asUser = $this->oDb->selectRow(
self::USER_TABLE,
$iUserId,
array('clearance', 'token', 'token_exp')
);
//Check token value
if(!empty($asUser)
&& $asUser['clearance'] == $asToken[1]
&& time() <= strtotime($asUser['token_exp'])
&& hash_equals($asUser['token'], hash('sha256', $sCookieValue))
) {
$this->setUserId($iUserId);
$this->setSession();
}
else $this->clearCookie();
}
private function setTokenCookie() {
$sToken = bin2hex(random_bytes(32));
$iClearance = $this->asUserInfo['clearance'];
$sCookieValue = implode(':', [$this->getUserId(), $iClearance, $sToken]);
$this->oDb->updateRow(
self::USER_TABLE,
$this->getUserId(),
array(
'token' => hash('sha256', $sCookieValue),
'token_exp' => date(Db::TIMESTAMP_FORMAT, time() + self::COOKIE_DURATION)
)
);
$this->setCookie($sCookieValue, time() + self::COOKIE_DURATION);
}
private function getCookie() {
return $_COOKIE[self::COOKIE_TOKEN] ?? '';
}
private function setCookie($sValue, $iExpires) {
setcookie(
self::COOKIE_TOKEN,
$sValue,
array(
'expires' => $iExpires,
'path' => '/',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'httponly' => true,
'samesite' => 'Lax'
)
);
}
private function clearCookie() {
$this->setCookie('', time() - 3600);
unset($_COOKIE[self::COOKIE_TOKEN]);
}
}