Updated authentication token format.

This commit is contained in:
flash 2023-05-21 18:15:04 +00:00
parent 5d602bd56f
commit 0bb1303413
12 changed files with 337 additions and 64 deletions

View file

@ -0,0 +1,53 @@
.impersonate {
--start-colour: var(--accent-colour);
--end-colour: var(--background-colour);
background-image: repeating-linear-gradient(-45deg, var(--start-colour), var(--start-colour) 10px, var(--end-colour) 10px, var(--end-colour) 20px);
height: 30px;
}
.impersonate-content {
max-width: var(--site-max-width);
margin: 0 auto;
display: flex;
justify-content: space-between;
height: 100%;
}
.impersonate-user {
padding: 4px 10px;
background-color: #222d;
}
.impersonate-user-link {
color: var(--user-colour);
text-decoration: none;
}
.impersonate-user-link:hover,
.impersonate-user-link:focus {
text-decoration: underline;
}
.impersonate-user-avatar {
display: inline-block;
}
.impersonate-options {
display: flex;
}
.impersonate-options-link {
width: 30px;
height: 30px;
line-height: 29px;
text-align: center;
font-size: 1.5em;
background-color: #222d;
display: block;
color: var(--text-colour);
text-decoration: none;
transition: background-color .1s;
}
.impersonate-options-link:focus,
.impersonate-options-link:hover {
background-color: #555d;
}
.impersonate-options-link:active {
background-color: #333d;
}

View file

@ -161,24 +161,27 @@ Template::set('globals', [
Template::addPath(MSZ_TEMPLATES); Template::addPath(MSZ_TEMPLATES);
AuthToken::setSecretKey($cfg->getValue('auth.secret', IConfig::T_STR, 'meow'));
if(isset($_COOKIE['msz_uid']) && isset($_COOKIE['msz_sid'])) { if(isset($_COOKIE['msz_uid']) && isset($_COOKIE['msz_sid'])) {
$authToken = (new AuthToken) $authToken = new AuthToken;
->setUserId(filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT) ?? 0) $authToken->setUserId(filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT) ?? 0);
->setSessionToken(filter_input(INPUT_COOKIE, 'msz_sid') ?? ''); $authToken->setSessionToken(filter_input(INPUT_COOKIE, 'msz_sid') ?? '');
if($authToken->isValid()) if($authToken->isValid())
setcookie('msz_auth', $authToken->pack(), strtotime('1 year'), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); $authToken->applyCookie(strtotime('1 year'));
setcookie('msz_uid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true); AuthToken::nukeCookieLegacy();
setcookie('msz_sid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
} }
if(!isset($authToken)) if(!isset($authToken))
$authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? ''); $authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? '');
if($authToken->isValid()) { if($authToken->isValid()) {
$authToken->setCurrent();
try { try {
$sessionInfo = $authToken->getSession(); $sessionInfo = UserSession::byToken($authToken->getSessionToken());
if($sessionInfo->hasExpired()) { if($sessionInfo->hasExpired()) {
$sessionInfo->delete(); $sessionInfo->delete();
} elseif($sessionInfo->getUserId() === $authToken->getUserId()) { } elseif($sessionInfo->getUserId() === $authToken->getUserId()) {
@ -189,7 +192,22 @@ if($authToken->isValid()) {
$sessionInfo->bump($_SERVER['REMOTE_ADDR']); $sessionInfo->bump($_SERVER['REMOTE_ADDR']);
if($sessionInfo->shouldBumpExpire()) if($sessionInfo->shouldBumpExpire())
setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); $authToken->applyCookie($sessionInfo->getExpiresTime());
// only allow impersonation when super user
if($authToken->hasImpersonatedUserId() && $userInfo->isSuper()) {
$userInfoReal = $userInfo;
try {
$userInfo = User::byId($authToken->getImpersonatedUserId());
} catch(UserNotFoundException $ex) {
$userInfo = $userInfoReal;
$authToken->removeImpersonatedUserId();
$authToken->applyCookie();
}
$userInfo->setCurrent();
}
} }
} }
} catch(UserNotFoundException $ex) { } catch(UserNotFoundException $ex) {
@ -202,10 +220,8 @@ if($authToken->isValid()) {
if(UserSession::hasCurrent()) { if(UserSession::hasCurrent()) {
$userInfo->bumpActivity($_SERVER['REMOTE_ADDR']); $userInfo->bumpActivity($_SERVER['REMOTE_ADDR']);
} else { } else
setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); AuthToken::nukeCookie();
setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true);
}
} }
CSRF::setGlobalSecretKey($cfg->getValue('csrf.secret', IConfig::T_STR, 'soup')); CSRF::setGlobalSecretKey($cfg->getValue('csrf.secret', IConfig::T_STR, 'soup'));
@ -248,6 +264,8 @@ if(parse_url($_SERVER['PHP_SELF'], PHP_URL_PATH) !== '/index.php')
if(!empty($userInfo)) if(!empty($userInfo))
Template::set('current_user', $userInfo); Template::set('current_user', $userInfo);
if(!empty($userInfoReal))
Template::set('current_user_real', $userInfoReal);
$inManageMode = str_starts_with($_SERVER['REQUEST_URI'], '/manage'); $inManageMode = str_starts_with($_SERVER['REQUEST_URI'], '/manage');
$hasManageAccess = User::hasCurrent() $hasManageAccess = User::hasCurrent()

View file

@ -119,7 +119,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
} }
$authToken = AuthToken::create($userInfo, $sessionInfo); $authToken = AuthToken::create($userInfo, $sessionInfo);
setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); $authToken->applyCookie($sessionInfo->getExpiresTime());
if(!is_local_url($loginRedirect)) if(!is_local_url($loginRedirect))
$loginRedirect = url('index'); $loginRedirect = url('index');

View file

@ -12,8 +12,7 @@ if(!UserSession::hasCurrent()) {
} }
if(CSRF::validateRequest()) { if(CSRF::validateRequest()) {
setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); AuthToken::nukeCookie();
setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true);
UserSession::getCurrent()->delete(); UserSession::getCurrent()->delete();
UserSession::unsetCurrent(); UserSession::unsetCurrent();
User::unsetCurrent(); User::unsetCurrent();

21
public/auth/revert.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
require_once '../../misuzu.php';
if(!isset($userInfoReal) || !$authToken->hasImpersonatedUserId() || !CSRF::validateRequest()) {
url_redirect('index');
return;
}
$authToken->removeImpersonatedUserId();
$authToken->applyCookie();
$impUserId = User::hasCurrent() ? User::getCurrent()->getId() : 0;
url_redirect(
$impUserId > 0 ? 'manage-user' : 'index',
['user' => $impUserId]
);

View file

@ -83,7 +83,7 @@ while(!empty($twofactor)) {
} }
$authToken = AuthToken::create($userInfo, $sessionInfo); $authToken = AuthToken::create($userInfo, $sessionInfo);
setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); $authToken->applyCookie($sessionInfo->getExpiresTime());
if(!is_local_url($redirect)) { if(!is_local_url($redirect)) {
$redirect = url('index'); $redirect = url('index');

View file

@ -31,6 +31,19 @@ $canEditPerms = $canEdit && perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ
$permissions = manage_perms_list(perms_get_user_raw($userId)); $permissions = manage_perms_list(perms_get_user_raw($userId));
if(CSRF::validateRequest() && $canEdit) { if(CSRF::validateRequest() && $canEdit) {
if(!empty($_POST['impersonate_user'])) {
if(!$currentUser->isSuper()) {
$notices[] = 'You must be a super user to do this.';
} elseif(!is_string($_POST['impersonate_user']) || $_POST['impersonate_user'] !== 'meow') {
$notices[] = 'You didn\'t say the magic word.';
} else {
$authToken->setImpersonatedUserId($userInfo->getId());
$authToken->applyCookie();
url_redirect('index');
return;
}
}
if(!empty($_POST['send_test_email'])) { if(!empty($_POST['send_test_email'])) {
if(!$currentUser->isSuper()) { if(!$currentUser->isSuper()) {
$notices[] = 'You must be a super user to do this.'; $notices[] = 'You must be a super user to do this.';

View file

@ -3,66 +3,103 @@ namespace Misuzu;
use Misuzu\Users\User; use Misuzu\Users\User;
use Misuzu\Users\UserSession; use Misuzu\Users\UserSession;
use Index\IO\MemoryStream;
use Index\Serialisation\Serialiser; use Index\Serialisation\Serialiser;
class AuthToken { class AuthToken {
public const VERSION = 1; private const EPOCH = 1682985600;
public const WIDTH = 37;
private $userId = -1; private int $timestamp = 0;
private $sessionToken = ''; private int $cookieExpires = 0;
private array $props = [];
private $user = null; private static string $secretKey = '';
private $session = null;
public static function setSecretKey(string $secretKey): void {
self::$secretKey = $secretKey;
}
public function getTimestamp(): int {
return $this->timestamp;
}
public function updateTimestamp(): void {
$this->timestamp = self::timestamp();
}
public function hasProperty(string $name): bool {
return isset($this->props[$name]);
}
public function getProperty(string $name): string {
return $this->props[$name] ?? '';
}
public function setProperty(string $name, string $value): void {
$this->props[$name] = $value;
$this->updateTimestamp();
}
public function removeProperty(string $name): void {
unset($this->props[$name]);
$this->updateTimestamp();
}
public function isValid(): bool { public function isValid(): bool {
return $this->getUserId() > 0 if($this->getUserId() < 1 || empty($this->getSessionToken()))
&& !empty($this->getSessionToken()); return false;
return true;
} }
public function getUserId(): int { public function getUserId(): int {
return $this->userId < 1 ? -1 : $this->userId; $value = (int)$this->getProperty('u');
return $value < 1 ? -1 : $value;
} }
public function setUserId(int $userId): self { public function setUserId(int $userId): self {
$this->user = null; $this->setProperty('u', (string)$userId);
$this->userId = $userId;
return $this;
}
public function getUser(): User {
if($this->user === null)
$this->user = User::byId($this->getUserId());
return $this->user;
}
public function setUser(User $user): self {
$this->user = $user;
$this->userId = $user->getId();
return $this; return $this;
} }
public function getSessionToken(): string { public function getSessionToken(): string {
return $this->sessionToken ?? ''; if(!$this->hasProperty('t'))
return '';
return bin2hex($this->getProperty('t'));
} }
public function setSessionToken(string $token): self { public function setSessionToken(string $token): self {
$this->session = null; $this->setProperty('t', hex2bin($token));
$this->sessionToken = $token;
return $this;
}
public function getSession(): UserSession {
if($this->session === null)
$this->session = UserSession::byToken($this->getSessionToken());
return $this->session;
}
public function setSession(UserSession $session): self {
$this->session = $session;
$this->sessionToken = $session->getToken();
return $this; return $this;
} }
public function hasImpersonatedUserId(): bool {
return $this->hasProperty('i');
}
public function getImpersonatedUserId(): int {
$value = (int)$this->getProperty('i');
return $value < 1 ? -1 : $value;
}
public function setImpersonatedUserId(int $userId): void {
$this->setProperty('i', (string)$userId);
}
public function removeImpersonatedUserId(): void {
$this->removeProperty('i');
}
public function pack(bool $base64 = true): string { public function pack(bool $base64 = true): string {
$packed = pack('CNH*', self::VERSION, $this->getUserId(), $this->getSessionToken()); $data = '';
foreach($this->props as $name => $value) {
// very smart solution for this issue, you definitely won't be confused by this later
// down the line when a variable suddenly despawns from the token
$nameLength = strlen($name);
$valueLength = strlen($value);
if($nameLength > 255 || $valueLength > 255)
continue;
$data .= chr($nameLength) . $name . chr($valueLength) . $value;
}
$prefix = pack('CN', 2, $this->getTimestamp());
$data = $prefix . hash_hmac('sha3-256', $prefix . $data, self::$secretKey, true) . $data;
if($base64) if($base64)
$packed = Serialiser::uriBase64()->serialise($packed); $data = Serialiser::uriBase64()->serialise($data);
return $packed;
return $data;
} }
public static function unpack(string $data, bool $base64 = true): self { public static function unpack(string $data, bool $base64 = true): self {
@ -72,20 +109,105 @@ class AuthToken {
return $obj; return $obj;
if($base64) if($base64)
$data = Serialiser::uriBase64()->deserialise($data); $data = Serialiser::uriBase64()->deserialise($data);
if(empty($data))
return $obj;
$data = str_pad($data, self::WIDTH, "\x00"); $version = ord($data[0]);
$data = unpack('Cversion/Nuser/H*token', $data); $data = substr($data, 1);
if($data['version'] >= 1) if($version === 1) {
$obj->setUserId($data['user']) $data = str_pad($data, 36, "\x00");
->setSessionToken($data['token']); $data = unpack('Nuser/H*token', $data);
$obj->props['u'] = (string)$data['user'];
$obj->props['t'] = hex2bin($data['token']);
$obj->updateTimestamp();
} elseif($version === 2) {
$timestamp = substr($data, 0, 4);
$userHash = substr($data, 4, 32);
$data = substr($data, 36);
$realHash = hash_hmac('sha3-256', chr($version) . $timestamp . $data, self::$secretKey, true);
if(!hash_equals($realHash, $userHash))
return $obj;
extract(unpack('Ntimestamp', $timestamp));
$obj->timestamp = $timestamp;
$stream = MemoryStream::fromString($data);
$stream->seek(0);
for(;;) {
$length = $stream->readChar();
if($length === null)
break;
$length = ord($length);
if($length < 1)
break;
$name = $stream->read($length);
$value = null;
$length = $stream->readChar();
if($length !== null) {
$length = ord($length);
if($length > 0)
$value = $stream->read($length);
}
$obj->props[$name] = $value;
}
}
return $obj; return $obj;
} }
public static function timestamp(): int {
return time() - self::EPOCH;
}
public static function create(User $user, UserSession $session): self { public static function create(User $user, UserSession $session): self {
return (new AuthToken) $token = new AuthToken;
->setUser($user) $token->setUserId($user->getId());
->setSession($session); $token->setSessionToken($session->getToken());
return $token;
}
public function applyCookie(int $expires = 0): void {
if($expires > 0)
$this->cookieExpires = $expires;
else
$expires = $this->cookieExpires;
setcookie('msz_auth', $this->pack(), $expires, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
}
public static function nukeCookie(): void {
setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true);
}
public static function nukeCookieLegacy(): void {
setcookie('msz_uid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
setcookie('msz_sid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
}
// please never use the below functions beyond the scope of the sharpchat auth stuff
// a better mechanism for keeping a global instance of this available
// that isn't a $GLOBAL variable or static instance needs to be established, for User and UserSession as well
private static $localToken = null;
public function setCurrent(): void {
self::$localToken = $this;
}
public static function unsetCurrent(): void {
self::$localToken = null;
}
public static function getCurrent(): ?self {
return self::$localToken;
}
public static function hasCurrent(): bool {
return self::$localToken !== null;
} }
} }

View file

@ -126,16 +126,22 @@ final class SharpChatRoutes {
if($request->getMethod() === 'OPTIONS') if($request->getMethod() === 'OPTIONS')
return 204; return 204;
if(!UserSession::hasCurrent()) if(!UserSession::hasCurrent() || !AuthToken::hasCurrent())
return ['ok' => false]; return ['ok' => false];
$token = AuthToken::getCurrent();
$session = UserSession::getCurrent(); $session = UserSession::getCurrent();
if($session->getToken() !== $token->getSessionToken())
return ['ok' => false];
$user = $session->getUser(); $user = $session->getUser();
$token = AuthToken::create($user, $session); $userId = $token->hasImpersonatedUserId() && $user->isSuper()
? $token->getImpersonatedUserId()
: $user->getId();
return [ return [
'ok' => true, 'ok' => true,
'usr' => $user->getId(), 'usr' => $userId,
'tkn' => $token->pack(), 'tkn' => $token->pack(),
]; ];
} }
@ -281,6 +287,15 @@ final class SharpChatRoutes {
$sessionInfo->bump($ipAddress); $sessionInfo->bump($ipAddress);
$userInfo = $sessionInfo->getUser(); $userInfo = $sessionInfo->getUser();
if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuper()) {
$userInfoReal = $userInfo;
try {
$userInfo = User::byId($authTokenInfo->getImpersonatedUserId());
} catch(UserNotFoundException $ex) {
$userInfo = $userInfoReal;
}
}
} else { } else {
return ['success' => false, 'reason' => 'unsupported']; return ['success' => false, 'reason' => 'unsupported'];
} }

View file

@ -22,6 +22,7 @@ define('MSZ_URLS', [
'auth-logout' => ['/auth/logout.php', ['csrf' => '{csrf}']], 'auth-logout' => ['/auth/logout.php', ['csrf' => '{csrf}']],
'auth-resolve-user' => ['/auth/login.php', ['resolve' => '1', 'name' => '<username>']], 'auth-resolve-user' => ['/auth/login.php', ['resolve' => '1', 'name' => '<username>']],
'auth-two-factor' => ['/auth/twofactor.php', ['token' => '<token>']], 'auth-two-factor' => ['/auth/twofactor.php', ['token' => '<token>']],
'auth-revert' => ['/auth/revert.php', ['csrf' => '{csrf}']],
'changelog-index' => ['/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>']], 'changelog-index' => ['/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>']],
'changelog-feed-rss' => ['/changelog.rss'], 'changelog-feed-rss' => ['/changelog.rss'],

View file

@ -1,6 +1,22 @@
{% from 'macros.twig' import avatar %} {% from 'macros.twig' import avatar %}
{% from '_layout/input.twig' import input_checkbox_raw %} {% from '_layout/input.twig' import input_checkbox_raw %}
{% if current_user_real is defined %}
<div class="impersonate">
<div class="impersonate-content">
<div class="impersonate-user" style="--user-colour: {{ current_user_real.colour }}">
You are <a href="{{ url('user-profile', {'user': current_user_real.id}) }}" class="impersonate-user-link">
<div class="avatar impersonate-user-avatar">{{ avatar(current_user_real.id, 20, current_user_real.username) }}</div>
{{ current_user_real.username }}
</a>
</div>
<div class="impersonate-options">
<a href="{{ url('auth-revert') }}" class="impersonate-options-link" title="Revert"><i class="fas fa-backward"></i></a>
</div>
</div>
</div>
{% endif %}
<nav class="header"> <nav class="header">
<div class="header__background"></div> <div class="header__background"></div>

View file

@ -145,6 +145,21 @@
<button class="input__button manage__user__button">Send</button> <button class="input__button manage__user__button">Send</button>
</div> </div>
</form> </form>
<form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">
{{ container_title('Impersonate ' ~ user_info.username ~ ' (' ~ user_info.id ~ ')') }}
<div class="container__content">
<p>Uses a special token to completely impersonate this user for testing.</p>
</div>
{{ input_csrf() }}
{{ input_hidden('impersonate_user', 'meow') }}
<div class="manage__user__buttons">
<button class="input__button manage__user__button">Impersonate</button>
</div>
</form>
{% endif %} {% endif %}
<form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container"> <form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">