Core Update 1.4.0
This commit is contained in:
@@ -9,6 +9,14 @@ class AesStream {
|
||||
private $callback;
|
||||
private ?string $outputFile;
|
||||
private ?string $inputFile;
|
||||
private int $offset;
|
||||
private ?int $length;
|
||||
|
||||
//
|
||||
private ?string $md5SumIn;
|
||||
private ?string $sha1SumIn;
|
||||
private ?string $md5SumOut;
|
||||
private ?string $sha1SumOut;
|
||||
|
||||
public function __construct(string $key, string $iv) {
|
||||
$this->key = $key;
|
||||
@@ -16,6 +24,12 @@ class AesStream {
|
||||
$this->inputFile = null;
|
||||
$this->outputFile = null;
|
||||
$this->callback = null;
|
||||
$this->offset = 0;
|
||||
$this->length = null;
|
||||
$this->md5SumIn = null;
|
||||
$this->sha1SumIn = null;
|
||||
$this->md5SumOut = null;
|
||||
$this->sha1SumOut = null;
|
||||
|
||||
if (!in_array(strlen($key), [16, 24, 32])) {
|
||||
throw new \Exception("Invalid Key Size");
|
||||
@@ -59,14 +73,23 @@ class AesStream {
|
||||
}
|
||||
|
||||
set_time_limit(0);
|
||||
$md5ContextIn = hash_init("md5");
|
||||
$sha1ContextIn = hash_init("sha1");
|
||||
$md5ContextOut = hash_init("md5");
|
||||
$sha1ContextOut = hash_init("sha1");
|
||||
|
||||
$ivCounter = $this->iv;
|
||||
$modulo = \gmp_init("0x1" . str_repeat("00", $blockSize), 16);
|
||||
$written = 0;
|
||||
|
||||
while (!feof($inputHandle)) {
|
||||
$chunk = fread($inputHandle, 65536);
|
||||
$chunkSize = strlen($chunk);
|
||||
if ($chunkSize > 0) {
|
||||
|
||||
hash_update($md5ContextIn, $chunk);
|
||||
hash_update($sha1ContextIn, $chunk);
|
||||
|
||||
$blockCount = intval(ceil($chunkSize / $blockSize));
|
||||
$encrypted = openssl_encrypt($chunk, $aesMode, $this->key, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $ivCounter);
|
||||
|
||||
@@ -76,18 +99,50 @@ class AesStream {
|
||||
$ivNumber = str_pad(\gmp_strval($ivNumber, 16), $blockSize * 2, "0", STR_PAD_LEFT);
|
||||
$ivCounter = hex2bin($ivNumber);
|
||||
|
||||
if ($this->callback !== null) {
|
||||
call_user_func($this->callback, $encrypted);
|
||||
// partial content
|
||||
$skip = false;
|
||||
if ($this->offset > 0 && $written < $this->offset) {
|
||||
if ($written + $chunkSize >= $this->offset) {
|
||||
$encrypted = substr($encrypted, $this->offset - $written);
|
||||
} else {
|
||||
$skip = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($outputHandle !== null) {
|
||||
fwrite($outputHandle, $encrypted);
|
||||
if ($this->length !== null) {
|
||||
$notSkipped = max($written - $this->offset, 0);
|
||||
if ($notSkipped + $chunkSize >= $this->length) {
|
||||
$encrypted = substr($encrypted, 0, $this->length - $notSkipped);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$skip) {
|
||||
if ($this->callback !== null) {
|
||||
call_user_func($this->callback, $encrypted);
|
||||
}
|
||||
|
||||
if ($outputHandle !== null) {
|
||||
fwrite($outputHandle, $encrypted);
|
||||
}
|
||||
|
||||
hash_update($md5ContextOut, $encrypted);
|
||||
hash_update($sha1ContextOut, $encrypted);
|
||||
}
|
||||
|
||||
$written += $chunkSize;
|
||||
if ($this->length !== null && $written - $this->offset >= $this->length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($inputHandle);
|
||||
if ($outputHandle) fclose($outputHandle);
|
||||
if ($outputHandle) {
|
||||
fclose($outputHandle);
|
||||
}
|
||||
|
||||
$this->md5SumIn = hash_final($md5ContextIn, false);
|
||||
$this->sha1SumIn = hash_final($sha1ContextIn, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -103,4 +158,25 @@ class AesStream {
|
||||
public function getIV(): string {
|
||||
return $this->iv;
|
||||
}
|
||||
|
||||
public function setRange(int $offset, int $length) {
|
||||
$this->offset = $offset;
|
||||
$this->length = $length;
|
||||
}
|
||||
|
||||
public function getMD5SumIn(): ?string {
|
||||
return $this->md5SumIn;
|
||||
}
|
||||
|
||||
public function getSHA1SumIn(): ?string {
|
||||
return $this->sha1SumIn;
|
||||
}
|
||||
|
||||
public function getMD5SumOut(): ?string {
|
||||
return $this->md5SumOut;
|
||||
}
|
||||
|
||||
public function getSHA1SumOut(): ?string {
|
||||
return $this->sha1SumOut;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ abstract class ApiObject implements \JsonSerializable {
|
||||
|
||||
public abstract function jsonSerialize(): array;
|
||||
|
||||
public function __toString() { return json_encode($this); }
|
||||
public function __toString() { return json_encode($this->jsonSerialize()); }
|
||||
|
||||
}
|
||||
|
||||
136
core/Objects/GpgKey.class.php
Normal file
136
core/Objects/GpgKey.class.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Objects;
|
||||
|
||||
class GpgKey extends ApiObject {
|
||||
|
||||
const GPG2 = "/usr/bin/gpg2";
|
||||
|
||||
private int $id;
|
||||
private bool $confirmed;
|
||||
private string $fingerprint;
|
||||
private string $algorithm;
|
||||
private \DateTime $expires;
|
||||
|
||||
public function __construct(int $id, bool $confirmed, string $fingerprint, string $algorithm, string $expires) {
|
||||
$this->id = $id;
|
||||
$this->confirmed = $confirmed;
|
||||
$this->fingerprint = $fingerprint;
|
||||
$this->algorithm = $algorithm;
|
||||
$this->expires = new \DateTime($expires);
|
||||
}
|
||||
|
||||
public static function encrypt(string $body, string $gpgFingerprint): array {
|
||||
$gpgFingerprint = escapeshellarg($gpgFingerprint);
|
||||
$cmd = self::GPG2 . " --encrypt --output - --recipient $gpgFingerprint --trust-model always --batch --armor";
|
||||
list($out, $err) = self::proc_exec($cmd, $body, true);
|
||||
if ($out === null) {
|
||||
return self::createError("Error while communicating with GPG agent");
|
||||
} else if ($err) {
|
||||
return self::createError($err);
|
||||
} else {
|
||||
return ["success" => true, "data" => $out];
|
||||
}
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return array(
|
||||
"fingerprint" => $this->fingerprint,
|
||||
"algorithm" => $this->algorithm,
|
||||
"expires" => $this->expires->getTimestamp(),
|
||||
"confirmed" => $this->confirmed
|
||||
);
|
||||
}
|
||||
|
||||
private static function proc_exec(string $cmd, ?string $stdin = null, bool $raw = false): ?array {
|
||||
$descriptorSpec = array(0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]);
|
||||
$process = proc_open($cmd, $descriptorSpec,$pipes);
|
||||
if (!is_resource($process)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($stdin) {
|
||||
fwrite($pipes[0], $stdin);
|
||||
fclose($pipes[0]);
|
||||
}
|
||||
|
||||
$out = stream_get_contents($pipes[1]);
|
||||
$err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_close($process);
|
||||
return [($raw ? $out : trim($out)), $err];
|
||||
}
|
||||
|
||||
private static function createError(string $error) : array {
|
||||
return ["success" => false, "error" => $error];
|
||||
}
|
||||
|
||||
public static function getKeyInfo(string $key): array {
|
||||
list($out, $err) = self::proc_exec(self::GPG2 . " --show-key", $key);
|
||||
if ($out === null) {
|
||||
return self::createError("Error while communicating with GPG agent");
|
||||
}
|
||||
|
||||
if ($err) {
|
||||
return self::createError($err);
|
||||
}
|
||||
|
||||
$lines = explode("\n", $out);
|
||||
if (count($lines) > 4) {
|
||||
return self::createError("It seems like you have uploaded more than one GPG-Key");
|
||||
} else if (count($lines) !== 4 || !preg_match("/(\S+)\s+(\w+)\s+.*\[expires: ([0-9-]+)]/", $lines[0], $matches)) {
|
||||
return self::createError("Error parsing GPG output");
|
||||
}
|
||||
|
||||
$keyType = $matches[1];
|
||||
$keyAlg = $matches[2];
|
||||
$expires = \DateTime::createFromFormat("Y-m-d", $matches[3]);
|
||||
$fingerprint = trim($lines[1]);
|
||||
$keyData = ["type" => $keyType, "algorithm" => $keyAlg, "expires" => $expires, "fingerprint" => $fingerprint];
|
||||
return ["success" => true, "data" => $keyData];
|
||||
}
|
||||
|
||||
public static function importKey(string $key): array {
|
||||
list($out, $err) = self::proc_exec(self::GPG2 . " --import", $key);
|
||||
if ($out === null) {
|
||||
return self::createError("Error while communicating with GPG agent");
|
||||
}
|
||||
|
||||
if (preg_match("/gpg:\s+Total number processed:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0) {
|
||||
if ((preg_match("/.*\s+unchanged:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0) ||
|
||||
(preg_match("/.*\s+imported:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0)) {
|
||||
return ["success" => true];
|
||||
}
|
||||
}
|
||||
|
||||
return self::createError($err);
|
||||
}
|
||||
|
||||
public static function export($gpgFingerprint, bool $armored): array {
|
||||
$cmd = self::GPG2 . " --export ";
|
||||
if ($armored) {
|
||||
$cmd .= "--armor ";
|
||||
}
|
||||
$cmd .= escapeshellarg($gpgFingerprint);
|
||||
list($out, $err) = self::proc_exec($cmd);
|
||||
if ($err) {
|
||||
return self::createError($err);
|
||||
}
|
||||
|
||||
return ["success" => true, "data" => $out];
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool {
|
||||
return $this->confirmed;
|
||||
}
|
||||
|
||||
public function getId(): int {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getFingerprint(): string {
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
}
|
||||
13
core/Objects/KeyBasedTwoFactorToken.class.php
Normal file
13
core/Objects/KeyBasedTwoFactorToken.class.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Objects;
|
||||
|
||||
class KeyBasedTwoFactorToken extends TwoFactorToken {
|
||||
|
||||
const TYPE = "fido2";
|
||||
|
||||
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
|
||||
parent::__construct(self::TYPE, $secret, $id, $confirmed);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,14 +47,16 @@ namespace Objects {
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function sendCookie() {
|
||||
setcookie('lang', $this->langCode, 0, "/", "");
|
||||
public function sendCookie(?string $domain = null) {
|
||||
$domain = empty($domain) ? "" : $domain;
|
||||
setcookie('lang', $this->langCode, 0, "/", $domain, false, false);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return array(
|
||||
'uid' => $this->languageId,
|
||||
'code' => $this->langCode,
|
||||
'shortCode' => explode("_", $this->langCode)[0],
|
||||
'name' => $this->langName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use External\JWT;
|
||||
class Session extends ApiObject {
|
||||
|
||||
# in minutes
|
||||
const DURATION = 60*24;
|
||||
const DURATION = 60*60*24*14;
|
||||
|
||||
private ?int $sessionId;
|
||||
private User $user;
|
||||
@@ -25,13 +25,14 @@ class Session extends ApiObject {
|
||||
public function __construct(User $user, ?int $sessionId, ?string $csrfToken) {
|
||||
$this->user = $user;
|
||||
$this->sessionId = $sessionId;
|
||||
$this->stayLoggedIn = true;
|
||||
$this->stayLoggedIn = false;
|
||||
$this->csrfToken = $csrfToken ?? generateRandomString(16);
|
||||
}
|
||||
|
||||
public static function create($user, $stayLoggedIn): ?Session {
|
||||
public static function create(User $user, bool $stayLoggedIn = false): ?Session {
|
||||
$session = new Session($user, null, null);
|
||||
if($session->insert($stayLoggedIn)) {
|
||||
if ($session->insert($stayLoggedIn)) {
|
||||
$session->stayLoggedIn = $stayLoggedIn;
|
||||
return $session;
|
||||
}
|
||||
|
||||
@@ -39,8 +40,8 @@ class Session extends ApiObject {
|
||||
}
|
||||
|
||||
private function updateMetaData() {
|
||||
$this->expires = time() + Session::DURATION * 60;
|
||||
$this->ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||
$this->expires = time() + Session::DURATION;
|
||||
$this->ipAddress = is_cli() ? "127.0.0.1" : $_SERVER['REMOTE_ADDR'];
|
||||
try {
|
||||
$userAgent = @get_browser($_SERVER['HTTP_USER_AGENT'], true);
|
||||
$this->os = $userAgent['platform'] ?? "Unknown";
|
||||
@@ -51,31 +52,36 @@ class Session extends ApiObject {
|
||||
}
|
||||
}
|
||||
|
||||
public function setData($data) {
|
||||
public function setData(array $data) {
|
||||
foreach($data as $key => $value) {
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function stayLoggedIn($val) {
|
||||
public function stayLoggedIn(bool $val) {
|
||||
$this->stayLoggedIn = $val;
|
||||
}
|
||||
|
||||
public function sendCookie() {
|
||||
public function getCookie(): string {
|
||||
$this->updateMetaData();
|
||||
$settings = $this->user->getConfiguration()->getSettings();
|
||||
$token = array('userId' => $this->user->getId(), 'sessionId' => $this->sessionId);
|
||||
$sessionCookie = JWT::encode($token, $settings->getJwtSecret());
|
||||
$token = ['userId' => $this->user->getId(), 'sessionId' => $this->sessionId];
|
||||
return JWT::encode($token, $settings->getJwtSecret());
|
||||
}
|
||||
|
||||
public function sendCookie(?string $domain = null) {
|
||||
$domain = empty($domain) ? "" : $domain;
|
||||
$sessionCookie = $this->getCookie();
|
||||
$secure = strcmp(getProtocol(), "https") === 0;
|
||||
setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", "", $secure, true);
|
||||
setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", $domain, $secure, true);
|
||||
}
|
||||
|
||||
public function getExpiresTime(): int {
|
||||
return ($this->stayLoggedIn == 0 ? 0 : $this->expires);
|
||||
return ($this->stayLoggedIn ? $this->expires : 0);
|
||||
}
|
||||
|
||||
public function getExpiresSeconds(): int {
|
||||
return ($this->stayLoggedIn == 0 ? -1 : $this->expires - time());
|
||||
return ($this->stayLoggedIn ? $this->expires - time() : -1);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
@@ -90,7 +96,7 @@ class Session extends ApiObject {
|
||||
);
|
||||
}
|
||||
|
||||
public function insert($stayLoggedIn): bool {
|
||||
public function insert(bool $stayLoggedIn = false): bool {
|
||||
$this->updateMetaData();
|
||||
$sql = $this->user->getSQL();
|
||||
|
||||
@@ -105,13 +111,13 @@ class Session extends ApiObject {
|
||||
$this->ipAddress,
|
||||
$this->os,
|
||||
$this->browser,
|
||||
json_encode($_SESSION),
|
||||
json_encode($_SESSION ?? []),
|
||||
$stayLoggedIn,
|
||||
$this->csrfToken)
|
||||
->returning("uid")
|
||||
->execute();
|
||||
|
||||
if($success) {
|
||||
if ($success) {
|
||||
$this->sessionId = $this->user->getSQL()->getLastInsertId();
|
||||
return true;
|
||||
}
|
||||
@@ -120,6 +126,7 @@ class Session extends ApiObject {
|
||||
}
|
||||
|
||||
public function destroy(): bool {
|
||||
session_destroy();
|
||||
return $this->user->getSQL()->update("Session")
|
||||
->set("active", false)
|
||||
->where(new Compare("Session.uid", $this->sessionId))
|
||||
@@ -138,7 +145,7 @@ class Session extends ApiObject {
|
||||
->where(new Compare("uid", $this->user->getId()))
|
||||
->execute() &&
|
||||
$sql->update("Session")
|
||||
->set("Session.expires", (new DateTime())->modify("+$minutes minute"))
|
||||
->set("Session.expires", (new DateTime())->modify("+$minutes second"))
|
||||
->set("Session.ipAddress", $this->ipAddress)
|
||||
->set("Session.os", $this->os)
|
||||
->set("Session.browser", $this->browser)
|
||||
|
||||
52
core/Objects/TimeBasedTwoFactorToken.class.php
Normal file
52
core/Objects/TimeBasedTwoFactorToken.class.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Objects;
|
||||
|
||||
use Base32\Base32;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
|
||||
class TimeBasedTwoFactorToken extends TwoFactorToken {
|
||||
|
||||
const TYPE = "totp";
|
||||
|
||||
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
|
||||
parent::__construct(self::TYPE, $secret, $id, $confirmed);
|
||||
}
|
||||
|
||||
public function getUrl(User $user): string {
|
||||
$otpType = self::TYPE;
|
||||
$name = rawurlencode($user->getUsername());
|
||||
$settings = $user->getConfiguration()->getSettings();
|
||||
$urlArgs = [
|
||||
"secret" => $this->getSecret(),
|
||||
"issuer" => $settings->getSiteName(),
|
||||
];
|
||||
|
||||
$urlArgs = http_build_query($urlArgs);
|
||||
return "otpauth://$otpType/$name?$urlArgs";
|
||||
}
|
||||
|
||||
public function generateQRCode(User $user) {
|
||||
$options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, "imageBase64" => false]);
|
||||
$qrcode = new QRCode($options);
|
||||
return $qrcode->render($this->getUrl($user));
|
||||
}
|
||||
|
||||
public function generate(?int $at = null, int $length = 6, int $period = 30): string {
|
||||
if ($at === null) {
|
||||
$at = time();
|
||||
}
|
||||
|
||||
$seed = intval($at / $period);
|
||||
$secret = Base32::decode($this->getSecret());
|
||||
$hmac = hash_hmac('sha1', pack("J", $seed), $secret, true);
|
||||
$offset = ord($hmac[-1]) & 0xF;
|
||||
$code = (unpack("N", substr($hmac, $offset, 4))[1] & 0x7fffffff) % intval(pow(10, $length));
|
||||
return substr(str_pad(strval($code), $length, "0", STR_PAD_LEFT), -1 * $length);
|
||||
}
|
||||
|
||||
public function verify(string $code): bool {
|
||||
return $this->generate() === $code;
|
||||
}
|
||||
}
|
||||
37
core/Objects/TwoFactor/AttestationObject.class.php
Normal file
37
core/Objects/TwoFactor/AttestationObject.class.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
|
||||
use Objects\ApiObject;
|
||||
|
||||
class AttestationObject extends ApiObject {
|
||||
|
||||
use \Objects\TwoFactor\CBORDecoder;
|
||||
|
||||
private string $format;
|
||||
private array $statement;
|
||||
private AuthenticationData $authData;
|
||||
|
||||
public function __construct(string $buffer) {
|
||||
$data = $this->decode($buffer)->getNormalizedData();
|
||||
$this->format = $data["fmt"];
|
||||
$this->statement = $data["attStmt"];
|
||||
$this->authData = new AuthenticationData($data["authData"]);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
"format" => $this->format,
|
||||
"statement" => [
|
||||
"sig" => base64_encode($this->statement["sig"] ?? ""),
|
||||
"x5c" => base64_encode(($this->statement["x5c"] ?? [""])[0]),
|
||||
],
|
||||
"authData" => $this->authData->jsonSerialize()
|
||||
];
|
||||
}
|
||||
|
||||
public function getAuthData(): AuthenticationData {
|
||||
return $this->authData;
|
||||
}
|
||||
|
||||
}
|
||||
79
core/Objects/TwoFactor/AuthenticationData.class.php
Normal file
79
core/Objects/TwoFactor/AuthenticationData.class.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
|
||||
use Objects\ApiObject;
|
||||
|
||||
class AuthenticationData extends ApiObject {
|
||||
|
||||
private string $rpIDHash;
|
||||
private int $flags;
|
||||
private int $counter;
|
||||
private string $aaguid;
|
||||
private string $credentialID;
|
||||
private PublicKey $publicKey;
|
||||
|
||||
public function __construct(string $buffer) {
|
||||
|
||||
if (strlen($buffer) < 32 + 1 + 4) {
|
||||
throw new \Exception("Invalid authentication data buffer size");
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
$this->rpIDHash = substr($buffer, $offset, 32); $offset += 32;
|
||||
$this->flags = ord($buffer[$offset]); $offset += 1;
|
||||
$this->counter = unpack("N", $buffer, $offset)[1]; $offset += 4;
|
||||
|
||||
if (strlen($buffer) >= $offset + 4 + 2) {
|
||||
$this->aaguid = substr($buffer, $offset, 16); $offset += 16;
|
||||
$credentialIdLength = unpack("n", $buffer, $offset)[1]; $offset += 2;
|
||||
$this->credentialID = substr($buffer, $offset, $credentialIdLength); $offset += $credentialIdLength;
|
||||
|
||||
$credentialData = substr($buffer, $offset);
|
||||
$this->publicKey = new PublicKey($credentialData);
|
||||
}
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
"rpIDHash" => base64_encode($this->rpIDHash),
|
||||
"flags" => $this->flags,
|
||||
"counter" => $this->counter,
|
||||
"aaguid" => base64_encode($this->aaguid),
|
||||
"credentialID" => base64_encode($this->credentialID),
|
||||
"publicKey" => $this->publicKey->jsonSerialize()
|
||||
];
|
||||
}
|
||||
|
||||
public function getHash(): string {
|
||||
return $this->rpIDHash;
|
||||
}
|
||||
|
||||
public function verifyIntegrity(string $rp): bool {
|
||||
return $this->rpIDHash === hash("sha256", $rp, true);
|
||||
}
|
||||
|
||||
public function isUserPresent(): bool {
|
||||
return boolval($this->flags & (1 << 0));
|
||||
}
|
||||
|
||||
public function isUserVerified(): bool {
|
||||
return boolval($this->flags & (1 << 2));
|
||||
}
|
||||
|
||||
public function attestedCredentialData(): bool {
|
||||
return boolval($this->flags & (1 << 6));
|
||||
}
|
||||
|
||||
public function hasExtensionData(): bool {
|
||||
return boolval($this->flags & (1 << 7));
|
||||
}
|
||||
|
||||
public function getPublicKey(): PublicKey {
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
public function getCredentialID() {
|
||||
return $this->credentialID;
|
||||
}
|
||||
}
|
||||
16
core/Objects/TwoFactor/CBORDecoder.trait.php
Normal file
16
core/Objects/TwoFactor/CBORDecoder.trait.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
|
||||
use CBOR\StringStream;
|
||||
|
||||
trait CBORDecoder {
|
||||
|
||||
protected function decode(string $buffer): \CBOR\CBORObject {
|
||||
$objectManager = new \CBOR\OtherObject\OtherObjectManager();
|
||||
$tagManager = new \CBOR\Tag\TagObjectManager();
|
||||
$decoder = new \CBOR\Decoder($tagManager, $objectManager);
|
||||
return $decoder->decode(new StringStream($buffer));
|
||||
}
|
||||
|
||||
}
|
||||
74
core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
Normal file
74
core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
|
||||
use Cose\Algorithm\Signature\ECDSA\ECSignature;
|
||||
|
||||
class KeyBasedTwoFactorToken extends TwoFactorToken {
|
||||
|
||||
const TYPE = "fido";
|
||||
|
||||
private ?string $challenge;
|
||||
private ?string $credentialId;
|
||||
private ?PublicKey $publicKey;
|
||||
|
||||
public function __construct(string $data, ?int $id = null, bool $confirmed = false) {
|
||||
parent::__construct(self::TYPE, $id, $confirmed);
|
||||
if (!$confirmed) {
|
||||
$this->challenge = base64_decode($data);
|
||||
$this->credentialId = null;
|
||||
$this->publicKey = null;
|
||||
} else {
|
||||
$jsonData = json_decode($data, true);
|
||||
$this->challenge = base64_decode($_SESSION["challenge"] ?? "");
|
||||
$this->credentialId = base64_decode($jsonData["credentialID"]);
|
||||
$this->publicKey = PublicKey::fromJson($jsonData["publicKey"]);
|
||||
}
|
||||
}
|
||||
|
||||
public function getData(): string {
|
||||
return $this->challenge;
|
||||
}
|
||||
|
||||
public function getPublicKey(): ?PublicKey {
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
public function getCredentialId() {
|
||||
return $this->credentialId;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
$json = parent::jsonSerialize();
|
||||
|
||||
if (!empty($this->challenge) && !$this->isAuthenticated()) {
|
||||
$json["challenge"] = base64_encode($this->challenge);
|
||||
}
|
||||
|
||||
if (!empty($this->credentialId)) {
|
||||
$json["credentialID"] = base64_encode($this->credentialId);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
// TODO: algorithms, hardcoded values, ...
|
||||
public function verify(string $signature, string $data): bool {
|
||||
switch ($this->publicKey->getUsedAlgorithm()) {
|
||||
case -7: // EC2
|
||||
|
||||
if (strlen($signature) !== 64) {
|
||||
$signature = \Cose\Algorithm\Signature\ECDSA\ECSignature::fromAsn1($signature, 64);
|
||||
}
|
||||
|
||||
$coseKey = new \Cose\Key\Key($this->publicKey->getNormalizedData());
|
||||
$ec2key = new \Cose\Key\Ec2Key($coseKey->getData());
|
||||
$publicKey = $ec2key->toPublic();
|
||||
$signature = ECSignature::toAsn1($signature, 64);
|
||||
return openssl_verify($data, $signature, $publicKey->asPEM(), "sha256") === 1;
|
||||
default:
|
||||
// Not implemented :(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
core/Objects/TwoFactor/PublicKey.class.php
Normal file
67
core/Objects/TwoFactor/PublicKey.class.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
|
||||
use Objects\ApiObject;
|
||||
|
||||
class PublicKey extends ApiObject {
|
||||
|
||||
use \Objects\TwoFactor\CBORDecoder;
|
||||
|
||||
private int $keyType;
|
||||
private int $usedAlgorithm;
|
||||
private int $curveType;
|
||||
private string $xCoordinate;
|
||||
private string $yCoordinate;
|
||||
|
||||
public function __construct(?string $cborData = null) {
|
||||
if ($cborData) {
|
||||
$data = $this->decode($cborData)->getNormalizedData();
|
||||
$this->keyType = $data["1"];
|
||||
$this->usedAlgorithm = $data["3"];
|
||||
$this->curveType = $data["-1"];
|
||||
$this->xCoordinate = $data["-2"];
|
||||
$this->yCoordinate = $data["-3"];
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromJson($jsonData): PublicKey {
|
||||
$publicKey = new PublicKey(null);
|
||||
$publicKey->keyType = $jsonData["keyType"];
|
||||
$publicKey->usedAlgorithm = $jsonData["usedAlgorithm"];
|
||||
$publicKey->curveType = $jsonData["curveType"];
|
||||
$publicKey->xCoordinate = base64_decode($jsonData["coordinates"]["x"]);
|
||||
$publicKey->yCoordinate = base64_decode($jsonData["coordinates"]["y"]);
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
public function getUsedAlgorithm(): int {
|
||||
return $this->usedAlgorithm;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
"keyType" => $this->keyType,
|
||||
"usedAlgorithm" => $this->usedAlgorithm,
|
||||
"curveType" => $this->curveType,
|
||||
"coordinates" => [
|
||||
"x" => base64_encode($this->xCoordinate),
|
||||
"y" => base64_encode($this->yCoordinate)
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getNormalizedData(): array {
|
||||
return [
|
||||
"1" => $this->keyType,
|
||||
"3" => $this->usedAlgorithm,
|
||||
"-1" => $this->curveType,
|
||||
"-2" => $this->xCoordinate,
|
||||
"-3" => $this->yCoordinate,
|
||||
];
|
||||
}
|
||||
|
||||
public function getU2F(): string {
|
||||
return bin2hex("\x04" . $this->xCoordinate . $this->yCoordinate);
|
||||
}
|
||||
}
|
||||
59
core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php
Normal file
59
core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
|
||||
use Base32\Base32;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Objects\User;
|
||||
|
||||
class TimeBasedTwoFactorToken extends TwoFactorToken {
|
||||
|
||||
const TYPE = "totp";
|
||||
private string $secret;
|
||||
|
||||
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
|
||||
parent::__construct(self::TYPE, $id, $confirmed);
|
||||
$this->secret = $secret;
|
||||
}
|
||||
|
||||
public function getUrl(User $user): string {
|
||||
$otpType = self::TYPE;
|
||||
$name = rawurlencode($user->getUsername());
|
||||
$settings = $user->getConfiguration()->getSettings();
|
||||
$urlArgs = [
|
||||
"secret" => $this->secret,
|
||||
"issuer" => $settings->getSiteName(),
|
||||
];
|
||||
|
||||
$urlArgs = http_build_query($urlArgs);
|
||||
return "otpauth://$otpType/$name?$urlArgs";
|
||||
}
|
||||
|
||||
public function generateQRCode(User $user) {
|
||||
$options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, "imageBase64" => false]);
|
||||
$qrcode = new QRCode($options);
|
||||
return $qrcode->render($this->getUrl($user));
|
||||
}
|
||||
|
||||
public function generate(?int $at = null, int $length = 6, int $period = 30): string {
|
||||
if ($at === null) {
|
||||
$at = time();
|
||||
}
|
||||
|
||||
$seed = intval($at / $period);
|
||||
$secret = Base32::decode($this->secret);
|
||||
$hmac = hash_hmac('sha1', pack("J", $seed), $secret, true);
|
||||
$offset = ord($hmac[-1]) & 0xF;
|
||||
$code = (unpack("N", substr($hmac, $offset, 4))[1] & 0x7fffffff) % intval(pow(10, $length));
|
||||
return substr(str_pad(strval($code), $length, "0", STR_PAD_LEFT), -1 * $length);
|
||||
}
|
||||
|
||||
public function verify(string $code): bool {
|
||||
return $this->generate() === $code;
|
||||
}
|
||||
|
||||
public function getData(): string {
|
||||
return $this->secret;
|
||||
}
|
||||
}
|
||||
62
core/Objects/TwoFactor/TwoFactorToken.class.php
Normal file
62
core/Objects/TwoFactor/TwoFactorToken.class.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Objects\TwoFactor;
|
||||
use Objects\ApiObject;
|
||||
|
||||
abstract class TwoFactorToken extends ApiObject {
|
||||
|
||||
private ?int $id;
|
||||
private string $type;
|
||||
private bool $confirmed;
|
||||
private bool $authenticated;
|
||||
|
||||
public function __construct(string $type, ?int $id = null, bool $confirmed = false) {
|
||||
$this->id = $id;
|
||||
$this->type = $type;
|
||||
$this->confirmed = $confirmed;
|
||||
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
"id" => $this->id,
|
||||
"type" => $this->type,
|
||||
"confirmed" => $this->confirmed,
|
||||
"authenticated" => $this->authenticated,
|
||||
];
|
||||
}
|
||||
|
||||
public abstract function getData(): string;
|
||||
|
||||
public function authenticate() {
|
||||
$this->authenticated = true;
|
||||
$_SESSION["2faAuthenticated"] = true;
|
||||
}
|
||||
|
||||
public function getType(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool {
|
||||
return $this->confirmed;
|
||||
}
|
||||
|
||||
public function getId(): int {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public static function newInstance(string $type, string $data, ?int $id = null, bool $confirmed = false) {
|
||||
if ($type === TimeBasedTwoFactorToken::TYPE) {
|
||||
return new TimeBasedTwoFactorToken($data, $id, $confirmed);
|
||||
} else if ($type === KeyBasedTwoFactorToken::TYPE) {
|
||||
return new KeyBasedTwoFactorToken($data, $id, $confirmed);
|
||||
} else {
|
||||
// TODO: error message
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAuthenticated(): bool {
|
||||
return $this->authenticated;
|
||||
}
|
||||
}
|
||||
63
core/Objects/TwoFactorToken.class.php
Normal file
63
core/Objects/TwoFactorToken.class.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Objects;
|
||||
|
||||
abstract class TwoFactorToken extends ApiObject {
|
||||
|
||||
private ?int $id;
|
||||
private string $type;
|
||||
private string $secret;
|
||||
private bool $confirmed;
|
||||
private bool $authenticated;
|
||||
|
||||
public function __construct(string $type, string $secret, ?int $id = null, bool $confirmed = false) {
|
||||
$this->id = $id;
|
||||
$this->type = $type;
|
||||
$this->secret = $secret;
|
||||
$this->confirmed = $confirmed;
|
||||
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
"id" => $this->id,
|
||||
"type" => $this->type,
|
||||
"confirmed" => $this->confirmed,
|
||||
"authenticated" => $this->authenticated,
|
||||
];
|
||||
}
|
||||
|
||||
public function authenticate() {
|
||||
$this->authenticated = true;
|
||||
$_SESSION["2faAuthenticated"] = true;
|
||||
}
|
||||
|
||||
public function getType(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getSecret(): string {
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool {
|
||||
return $this->confirmed;
|
||||
}
|
||||
|
||||
public function getId(): int {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public static function newInstance(string $type, string $secret, ?int $id = null, bool $confirmed = false) {
|
||||
if ($type === TimeBasedTwoFactorToken::TYPE) {
|
||||
return new TimeBasedTwoFactorToken($secret, $id, $confirmed);
|
||||
} else {
|
||||
// TODO: error message
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAuthenticated(): bool {
|
||||
return $this->authenticated;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ use External\JWT;
|
||||
use Driver\SQL\SQL;
|
||||
use Driver\SQL\Condition\Compare;
|
||||
use Driver\SQL\Condition\CondBool;
|
||||
use Objects\TwoFactor\TwoFactorToken;
|
||||
|
||||
// TODO: User::authorize and User::readData have similar function body
|
||||
class User extends ApiObject {
|
||||
|
||||
private ?SQL $sql;
|
||||
@@ -22,6 +24,8 @@ class User extends ApiObject {
|
||||
private ?string $profilePicture;
|
||||
private Language $language;
|
||||
private array $groups;
|
||||
private ?GpgKey $gpgKey;
|
||||
private ?TwoFactorToken $twoFactorToken;
|
||||
|
||||
public function __construct($configuration) {
|
||||
$this->configuration = $configuration;
|
||||
@@ -66,6 +70,8 @@ class User extends ApiObject {
|
||||
public function getConfiguration(): Configuration { return $this->configuration; }
|
||||
public function getGroups(): array { return $this->groups; }
|
||||
public function hasGroup(int $group): bool { return isset($this->groups[$group]); }
|
||||
public function getGPG(): ?GpgKey { return $this->gpgKey; }
|
||||
public function getTwoFactorToken(): ?TwoFactorToken { return $this->twoFactorToken; }
|
||||
public function getProfilePicture() : ?string { return $this->profilePicture; }
|
||||
|
||||
public function __debugInfo(): array {
|
||||
@@ -93,6 +99,8 @@ class User extends ApiObject {
|
||||
'groups' => $this->groups,
|
||||
'language' => $this->language->jsonSerialize(),
|
||||
'session' => $this->session->jsonSerialize(),
|
||||
"gpg" => ($this->gpgKey ? $this->gpgKey->jsonSerialize() : null),
|
||||
"2fa" => ($this->twoFactorToken ? $this->twoFactorToken->jsonSerialize() : null),
|
||||
);
|
||||
} else {
|
||||
return array(
|
||||
@@ -109,11 +117,13 @@ class User extends ApiObject {
|
||||
$this->loggedIn = false;
|
||||
$this->session = null;
|
||||
$this->profilePicture = null;
|
||||
$this->gpgKey = null;
|
||||
$this->twoFactorToken = null;
|
||||
}
|
||||
|
||||
public function logout(): bool {
|
||||
$success = true;
|
||||
if($this->loggedIn) {
|
||||
if ($this->loggedIn) {
|
||||
$success = $this->session->destroy();
|
||||
$this->reset();
|
||||
}
|
||||
@@ -131,11 +141,15 @@ class User extends ApiObject {
|
||||
}
|
||||
|
||||
public function sendCookies() {
|
||||
if($this->loggedIn) {
|
||||
$this->session->sendCookie();
|
||||
|
||||
$baseUrl = $this->getConfiguration()->getSettings()->getBaseUrl();
|
||||
$domain = parse_url($baseUrl, PHP_URL_HOST);
|
||||
|
||||
if ($this->loggedIn) {
|
||||
$this->session->sendCookie($domain);
|
||||
}
|
||||
|
||||
$this->language->sendCookie();
|
||||
$this->language->sendCookie($domain);
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
@@ -147,7 +161,11 @@ class User extends ApiObject {
|
||||
*/
|
||||
public function readData($userId, $sessionId, bool $sessionUpdate = true): bool {
|
||||
|
||||
$res = $this->sql->select("User.name", "User.email", "User.fullName", "User.profilePicture",
|
||||
$res = $this->sql->select("User.name", "User.email", "User.fullName",
|
||||
"User.profilePicture",
|
||||
"User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint",
|
||||
"GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm",
|
||||
"User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type",
|
||||
"Language.uid as langId", "Language.code as langCode", "Language.name as langName",
|
||||
"Session.data", "Session.stay_logged_in", "Session.csrf_token", "Group.uid as groupId", "Group.name as groupName")
|
||||
->from("User")
|
||||
@@ -155,6 +173,8 @@ class User extends ApiObject {
|
||||
->leftJoin("Language", "User.language_id", "Language.uid")
|
||||
->leftJoin("UserGroup", "UserGroup.user_id", "User.uid")
|
||||
->leftJoin("Group", "UserGroup.group_id", "Group.uid")
|
||||
->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid")
|
||||
->leftJoin("2FA", "User.2fa_id", "2FA.uid")
|
||||
->where(new Compare("User.uid", $userId))
|
||||
->where(new Compare("Session.uid", $sessionId))
|
||||
->where(new Compare("Session.active", true))
|
||||
@@ -175,11 +195,21 @@ class User extends ApiObject {
|
||||
$this->profilePicture = $row["profilePicture"];
|
||||
|
||||
$this->session = new Session($this, $sessionId, $csrfToken);
|
||||
$this->session->setData(json_decode($row["data"] ?? '{}'));
|
||||
$this->session->stayLoggedIn($this->sql->parseBool(["stay_logged_in"]));
|
||||
if($sessionUpdate) $this->session->update();
|
||||
$this->session->setData(json_decode($row["data"] ?? '{}', true));
|
||||
$this->session->stayLoggedIn($this->sql->parseBool($row["stay_logged_in"]));
|
||||
if ($sessionUpdate) $this->session->update();
|
||||
$this->loggedIn = true;
|
||||
|
||||
if (!empty($row["gpg_id"])) {
|
||||
$this->gpgKey = new GpgKey($row["gpg_id"], $this->sql->parseBool($row["gpg_confirmed"]),
|
||||
$row["gpg_fingerprint"], $row["gpg_algorithm"], $row["gpg_expires"]);
|
||||
}
|
||||
|
||||
if (!empty($row["2fa_id"])) {
|
||||
$this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"],
|
||||
$row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"]));
|
||||
}
|
||||
|
||||
if(!is_null($row['langId'])) {
|
||||
$this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));
|
||||
}
|
||||
@@ -194,7 +224,7 @@ class User extends ApiObject {
|
||||
}
|
||||
|
||||
private function parseCookies() {
|
||||
if(isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
|
||||
if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
|
||||
try {
|
||||
$token = $_COOKIE['session'];
|
||||
$settings = $this->configuration->getSettings();
|
||||
@@ -218,7 +248,7 @@ class User extends ApiObject {
|
||||
}
|
||||
}
|
||||
|
||||
public function createSession($userId, $stayLoggedIn): bool {
|
||||
public function createSession(int $userId, bool $stayLoggedIn = false): bool {
|
||||
$this->uid = $userId;
|
||||
$this->session = Session::create($this, $stayLoggedIn);
|
||||
if ($this->session) {
|
||||
@@ -237,6 +267,9 @@ class User extends ApiObject {
|
||||
|
||||
$res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.fullName", "User.email",
|
||||
"User.confirmed", "User.profilePicture",
|
||||
"User.gpg_id", "GpgKey.fingerprint as gpg_fingerprint", "GpgKey.expires as gpg_expires",
|
||||
"GpgKey.confirmed as gpg_confirmed", "GpgKey.algorithm as gpg_algorithm",
|
||||
"User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type",
|
||||
"Language.uid as langId", "Language.code as langCode", "Language.name as langName",
|
||||
"Group.uid as groupId", "Group.name as groupName")
|
||||
->from("ApiKey")
|
||||
@@ -244,6 +277,8 @@ class User extends ApiObject {
|
||||
->leftJoin("UserGroup", "UserGroup.user_id", "User.uid")
|
||||
->leftJoin("Group", "UserGroup.group_id", "Group.uid")
|
||||
->leftJoin("Language", "User.language_id", "Language.uid")
|
||||
->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid")
|
||||
->leftJoin("2FA", "User.2fa_id", "2FA.uid")
|
||||
->where(new Compare("ApiKey.api_key", $apiKey))
|
||||
->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
|
||||
->where(new Compare("ApiKey.active", 1))
|
||||
@@ -265,6 +300,17 @@ class User extends ApiObject {
|
||||
$this->email = $row['email'];
|
||||
$this->profilePicture = $row["profilePicture"];
|
||||
|
||||
if (!empty($row["gpg_id"])) {
|
||||
$this->gpgKey = new GpgKey($row["gpg_id"], $this->sql->parseBool($row["gpg_confirmed"]),
|
||||
$row["gpg_fingerprint"], $row["gpg_algorithm"], $row["gpg_expires"]
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($row["2fa_id"])) {
|
||||
$this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"],
|
||||
$row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"]));
|
||||
}
|
||||
|
||||
if(!is_null($row['langId'])) {
|
||||
$this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user