Backlogged things.
This commit is contained in:
parent
d9e175699e
commit
caeb4af8ef
22 changed files with 877 additions and 63 deletions
|
@ -1 +1 @@
|
|||
Subproject commit fbe4fe18decd502a0ca15ffe8a7c3b2d847349d5
|
||||
Subproject commit bce5ba77a268ecd6338d0e3520e41ff4c40cbeda
|
163
public/assets/hanyuu.css
Normal file
163
public/assets/hanyuu.css
Normal file
|
@ -0,0 +1,163 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
font: 12px/20px Verdana, Geneva, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
body {
|
||||
background-color: #ddd;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
[hidden],
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.http-err {
|
||||
padding: 40px 60px;
|
||||
}
|
||||
.http-err-title {
|
||||
font-size: 3em;
|
||||
line-height: 1.5em;
|
||||
font-weight: 700;
|
||||
}
|
||||
.http-err-paragraph {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.auth {
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
padding-top: 10vh;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
font-size: 5em;
|
||||
line-height: 1.5em;
|
||||
color: #a77bca;
|
||||
text-shadow: 0 1px 5px #a77bca;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.auth-avatar {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.auth-avatar-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
border-radius: 5%;
|
||||
display: inline-block;
|
||||
}
|
||||
.auth-avatar img {
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.auth-login {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-fields {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-left: 5px solid #aaa;
|
||||
margin: 10px 0;
|
||||
padding: 2px 7px;
|
||||
padding-right: 12px;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.auth-input:focus-within {
|
||||
border-color: #a77bca;
|
||||
}
|
||||
.auth-input-title {
|
||||
display: block;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.auth-input-value {
|
||||
display: block;
|
||||
}
|
||||
.auth-input-value input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-width: 0;
|
||||
background-color: inherit;
|
||||
outline-style: none !important;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.5em;
|
||||
font-family: Verdana, Geneva, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.auth-buttons {
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: Verdana, Geneva, Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.5em;
|
||||
background-color: #bbb;
|
||||
border-width: 0;
|
||||
border-radius: 5px;
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
transition: background-color .2s;
|
||||
}
|
||||
.auth-button:hover,
|
||||
.auth-button:focus {
|
||||
background-color: #ccc;
|
||||
}
|
||||
.auth-button:active {
|
||||
background-color: #aaa;
|
||||
}
|
||||
|
||||
.auth-button-primary {
|
||||
font-size: 1.3em;
|
||||
background-color: #a77bca;
|
||||
color: #fff;
|
||||
}
|
||||
.auth-button-primary:hover,
|
||||
.auth-button-primary:focus {
|
||||
background-color: #b28bd1;
|
||||
}
|
||||
.auth-button-primary:active {
|
||||
background-color: #925bbd;
|
||||
}
|
0
public/assets/hanyuu.js
Normal file
0
public/assets/hanyuu.js
Normal file
7
src/Auth/Auth.php
Normal file
7
src/Auth/Auth.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Hanyuu\Auth;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
abstract class Auth {}
|
281
src/Auth/AuthRoutes.php
Normal file
281
src/Auth/AuthRoutes.php
Normal file
|
@ -0,0 +1,281 @@
|
|||
<?php
|
||||
namespace Hanyuu\Auth;
|
||||
|
||||
use Index\Routing\IRouter;
|
||||
use Hanyuu\HanyuuContext;
|
||||
use Hanyuu\Auth\Auth;
|
||||
use Hanyuu\Auth\IAuthLogin;
|
||||
use Hanyuu\Config\IConfig;
|
||||
use Hanyuu\Users\IUserInfo;
|
||||
use Hanyuu\Users\IUsers;
|
||||
use Hanyuu\Users\UserNotFoundException;
|
||||
|
||||
// VERY IMPORTANT TODO: CSRF AND RATE LIMITING
|
||||
|
||||
class AuthRoutes {
|
||||
private HanyuuContext $context;
|
||||
private IUsers $users;
|
||||
private IConfig $config;
|
||||
private Auth $auth;
|
||||
|
||||
private ?IAuthLogin $loginSession;
|
||||
|
||||
public function __construct(
|
||||
IRouter $router,
|
||||
HanyuuContext $ctx,
|
||||
IConfig $config
|
||||
) {
|
||||
$this->context = $ctx;
|
||||
$this->config = $config;
|
||||
$this->users = $ctx->getUsers();
|
||||
|
||||
$this->context->setUpAuth();
|
||||
$this->auth = $ctx->getAuth();
|
||||
|
||||
$router->use('/login', [$this, 'filterLogin']);
|
||||
|
||||
$router->get('/login', [$this, 'getLogin']);
|
||||
$router->post('/login', [$this, 'postLogin']);
|
||||
|
||||
$router->get('/login/tfa', [$this, 'getLoginTFA']);
|
||||
$router->get('/login/done', [$this, 'getLoginDone']);
|
||||
|
||||
$router->get('/login/tfa/totp', [$this, 'getLoginTOTP']);
|
||||
$router->post('/login/tfa/totp', [$this, 'postLoginTOTP']);
|
||||
|
||||
$router->get('/login/tfa/u2f', [$this, 'getLoginU2F']);
|
||||
$router->post('/login/tfa/u2f', [$this, 'postLoginU2F']);
|
||||
|
||||
$router->get('/login/tfa/backup', [$this, 'getLoginBackup']);
|
||||
$router->post('/login/tfa/backup', [$this, 'postLoginBackup']);
|
||||
|
||||
$router->get('/register', [$this, 'getRegister']);
|
||||
|
||||
$router->get('/forgot-username', [$this, 'getForgotUserName']);
|
||||
|
||||
$router->get('/forgot-password', [$this, 'getForgotPassword']);
|
||||
$router->get('/recover-password', [$this, 'getRecoverPassword']);
|
||||
}
|
||||
|
||||
private function getLoginCookieName(): string {
|
||||
return $this->config->getValue('login_cookie', IConfig::T_STR, 'hau_login');
|
||||
}
|
||||
|
||||
private function destroyLoginSession($response): void {
|
||||
if($this->loginSession !== null) {
|
||||
$response->removeCookie($this->getLoginCookieName());
|
||||
$this->auth->destroyLoginSession($this->loginSession);
|
||||
$this->loginSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureLoginIncomplete($response): bool {
|
||||
$loginSession = $this->loginSession;
|
||||
if($loginSession === null) {
|
||||
$response->redirect('/login');
|
||||
return false;
|
||||
}
|
||||
|
||||
if($loginSession->getFactorsDone() >= $loginSession->getFactorsRequired()) {
|
||||
$response->redirect('/login/done');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function filterLogin($response, $request) {
|
||||
$loginId = (string)$request->getCookie($this->getLoginCookieName());
|
||||
$this->loginSession = empty($loginId) ? null : $this->auth->getLoginSessionById($loginId);
|
||||
|
||||
if($this->loginSession !== null) {
|
||||
if(!$this->loginSession->isValid()
|
||||
|| $this->loginSession->getRemoteAddress()->getCleanAddress() !== $_SERVER['REMOTE_ADDR'])
|
||||
$this->destroyLoginSession($response);
|
||||
}
|
||||
}
|
||||
|
||||
public function getLogin($response, $request) {
|
||||
$this->destroyLoginSession($response);
|
||||
|
||||
$userName = (string)$request->getParam('username');
|
||||
$errorId = (string)$request->getParam('error');
|
||||
$errorText = [
|
||||
'invalid_username' => 'Either no username was provided or it was incorrectly formatted.',
|
||||
'wrong_password' => 'The provided password was incorrect.',
|
||||
'user_not_found' => 'No user with this name exists. NOTE: point to registration somehow',
|
||||
'user_deleted' => 'Your account has been marked for deletion. Contact staff to revert this.',
|
||||
][$errorId] ?? '';
|
||||
|
||||
return $this->context->renderTemplate('auth/login', [
|
||||
'userName' => $userName,
|
||||
'errorId' => $errorId,
|
||||
'errorText' => $errorText,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postLogin($response, $request) {
|
||||
if(!$request->isFormContent())
|
||||
return 400;
|
||||
$form = $request->getContent();
|
||||
|
||||
// should be adjusted to respond with json to ajax shit
|
||||
|
||||
$this->destroyLoginSession($response);
|
||||
|
||||
$userName = (string)$form->getParam('username');
|
||||
$password = (string)$form->getParam('password');
|
||||
|
||||
if(empty($userName)) {
|
||||
$response->redirect('/login?error=invalid_username');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$userInfo = $this->users->getUserInfoByName($userName);
|
||||
} catch(UserNotFoundException $ex) {
|
||||
$response->redirect('/login?error=user_not_found&username=' . rawurlencode($userName));
|
||||
return;
|
||||
}
|
||||
|
||||
if($userInfo->isDeleted()) {
|
||||
$response->redirect('/login?error=user_deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
$passwordInfo = $this->users->getUserPasswordInfo($userInfo);
|
||||
if(!$passwordInfo->verifyPassword($password)) {
|
||||
$response->redirect('/login?error=wrong_password');
|
||||
return;
|
||||
}
|
||||
|
||||
$factorsRequired = $this->users->countUserTFAMethods($userInfo) > 0 ? 2 : 1;
|
||||
$loginId = $this->auth->createLoginSession($userInfo, $_SERVER['REMOTE_ADDR'], 'A1', $factorsRequired);
|
||||
$this->loginSession = $this->auth->getLoginSessionById($loginId);
|
||||
|
||||
$this->auth->incrementLoginSessionDone($this->loginSession);
|
||||
|
||||
// check for rehash
|
||||
|
||||
$response->addCookie($this->getLoginCookieName(), $loginId, strtotime('+30 minutes'), '/', '', true, true, true);
|
||||
|
||||
$response->redirect($factorsRequired > 1 ? '/login/tfa' : '/login/done');
|
||||
}
|
||||
|
||||
public function getLoginTFA($response, $request) {
|
||||
if(!$this->ensureLoginIncomplete($response))
|
||||
return;
|
||||
|
||||
$errorId = (string)$request->getParam('error');
|
||||
$errorText = [
|
||||
'invalid_method' => 'Either no method was provided or it was incorrectly formatted.',
|
||||
][$errorId] ?? '';
|
||||
|
||||
$userInfo = $this->users->getUserInfoById($this->loginSession->getUserId());
|
||||
$authMethods = $this->users->getUserTFAMethods($userInfo);
|
||||
$authMethodNames = [];
|
||||
foreach($authMethods as $authMethod)
|
||||
$authMethodNames[] = $authMethod->getType();
|
||||
|
||||
if(count($authMethods) === 1) {
|
||||
$response->redirect("/login/tfa/{$authMethods[0]->getType()}");
|
||||
return;
|
||||
}
|
||||
|
||||
return $this->context->renderTemplate('auth/login-tfa', [
|
||||
'userInfo' => $userInfo,
|
||||
'authMethods' => $authMethods,
|
||||
'authMethodNames' => $authMethodNames,
|
||||
'loginSession' => $this->loginSession,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getLoginDone($response, $request) {
|
||||
if($this->loginSession === null) {
|
||||
$response->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if($this->loginSession->getFactorsDone() < $this->loginSession->getFactorsRequired()) {
|
||||
$response->redirect('/login/pick');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->destroyLoginSession($response);
|
||||
|
||||
return "you've successfully authenticated but there's no session system yet lol";
|
||||
}
|
||||
|
||||
public function getLoginTOTP($response, $request) {
|
||||
if(!$this->ensureLoginIncomplete($response))
|
||||
return;
|
||||
|
||||
$userInfo = $this->users->getUserInfoById($this->loginSession->getUserId());
|
||||
|
||||
$errorId = (string)$request->getParam('error');
|
||||
$errorText = [
|
||||
'invalid_method' => 'Either no method was provided or it was incorrectly formatted.',
|
||||
][$errorId] ?? '';
|
||||
|
||||
return $this->context->renderTemplate('auth/login-totp', [
|
||||
'userInfo' => $userInfo,
|
||||
'loginSession' => $this->loginSession,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postLoginTOTP($response, $request) {
|
||||
if(!$request->isFormContent())
|
||||
return 400;
|
||||
$form = $request->getContent();
|
||||
|
||||
if(!$this->ensureLoginIncomplete($response))
|
||||
return;
|
||||
|
||||
$userInfo = $this->users->getUserInfoById($this->loginSession->getUserId());
|
||||
|
||||
$totpInfo = $this->users->getUserTOTPInfo($userInfo);
|
||||
$totpValid = $totpInfo->generateValidCodes();
|
||||
$totpCode = (string)$form->getParam('code');
|
||||
|
||||
if(!in_array($totpCode, $totpValid, true)) {
|
||||
$response->redirect('/login/tfa/totp?error=wrong_totp');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->auth->incrementLoginSessionDone($this->loginSession);
|
||||
|
||||
$response->redirect('/login/done');
|
||||
}
|
||||
|
||||
public function getLoginU2F($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function postLoginU2F($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function getLoginBackup($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function postLoginBackup($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function getRegister($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function getForgotUserName($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function getForgotPassword($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
public function getRecoverPassword($response, $request) {
|
||||
return 503;
|
||||
}
|
||||
}
|
86
src/Auth/Db/DbAuth.php
Normal file
86
src/Auth/Db/DbAuth.php
Normal file
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
namespace Hanyuu\Auth\Db;
|
||||
|
||||
use Index\XString;
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Data\IDbResult;
|
||||
use Index\Data\DbType;
|
||||
use Hanyuu\StatementCache;
|
||||
use Hanyuu\Auth\IAuthLogin;
|
||||
use Hanyuu\Auth\IAuthMethod;
|
||||
use Hanyuu\Auth\Auth;
|
||||
use Hanyuu\Users\IUserInfo;
|
||||
|
||||
class DbAuth extends Auth {
|
||||
private const LOGINS_TABLE = 'hau_auth_logins';
|
||||
|
||||
private const LOGINS_FIELDS = [
|
||||
'auth_login_id', 'user_id', 'auth_login_ip', 'auth_login_country',
|
||||
'auth_login_factors_required', 'auth_login_factors_done',
|
||||
'UNIX_TIMESTAMP(auth_login_started)', 'UNIX_TIMESTAMP(auth_login_valid)', 'UNIX_TIMESTAMP(auth_login_completed)',
|
||||
];
|
||||
|
||||
private StatementCache $stmts;
|
||||
|
||||
public function __construct(
|
||||
private IDbConnection $conn
|
||||
) {
|
||||
$this->stmts = new StatementCache($conn);
|
||||
}
|
||||
|
||||
|
||||
public function createLoginSession(
|
||||
IUserInfo $userInfo,
|
||||
string $remoteAddr,
|
||||
string $countryCode,
|
||||
int $factors
|
||||
): string {
|
||||
$loginId = XString::random(48);
|
||||
|
||||
$stmt = $this->stmts->getStatement('create login', function() {
|
||||
return 'INSERT INTO ' . self::LOGINS_TABLE
|
||||
. ' (auth_login_id, user_id, auth_login_ip, auth_login_country, auth_login_factors_required)'
|
||||
. ' VALUES (?, ?, INET6_ATON(?), ?, ?)';
|
||||
});
|
||||
|
||||
$stmt->addParameter(1, $loginId, DbType::STRING);
|
||||
$stmt->addParameter(2, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->addParameter(3, $remoteAddr, DbType::STRING);
|
||||
$stmt->addParameter(4, $countryCode, DbType::STRING);
|
||||
$stmt->addParameter(5, $factors, DbType::INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
return $loginId;
|
||||
}
|
||||
|
||||
public function destroyLoginSession(IAuthLogin $loginInfo): void {
|
||||
$stmt = $this->stmts->getStatement('destroy login', fn() => ('DELETE FROM ' . self::LOGINS_TABLE . ' WHERE auth_login_id = ?'));
|
||||
$stmt->addParameter(1, $loginInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
private function fetchLoginSingle(IDbResult $result, string $exceptionText): IUserInfo {
|
||||
if(!$result->next())
|
||||
throw new AuthLoginNotFoundException($exceptionText);
|
||||
|
||||
return new DbUserInfo($result);
|
||||
}
|
||||
|
||||
public function getLoginSessionById(string $loginId): ?IAuthLogin {
|
||||
$stmt = $this->stmts->getStatement('get login by id', fn() => ('SELECT ' . implode(',', self::LOGINS_FIELDS) . ' FROM ' . self::LOGINS_TABLE . ' WHERE auth_login_id = ?'));
|
||||
$stmt->addParameter(1, $loginId, DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
||||
if(!$result->next())
|
||||
return null;
|
||||
|
||||
return new DbAuthLogin($result);
|
||||
}
|
||||
|
||||
public function incrementLoginSessionDone(IAuthLogin $login): void {
|
||||
$stmt = $this->stmts->getStatement('increment login done', fn() => ('UPDATE ' . self::LOGINS_TABLE . ' SET auth_login_factors_done = auth_login_factors_done + 1 WHERE auth_login_id = ?'));
|
||||
$stmt->addParameter(1, $login->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
77
src/Auth/Db/DbAuthLogin.php
Normal file
77
src/Auth/Db/DbAuthLogin.php
Normal file
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
namespace Hanyuu\Auth\Db;
|
||||
|
||||
use Index\DateTime;
|
||||
use Index\Data\IDbResult;
|
||||
use Index\Net\IPAddress;
|
||||
use Hanyuu\Auth\IAuthLogin;
|
||||
|
||||
class DbAuthLogin implements IAuthLogin {
|
||||
private string $id;
|
||||
private string $userId;
|
||||
private IPAddress $remoteAddr;
|
||||
private string $country;
|
||||
private int $factorsRequired;
|
||||
private int $factorsDone;
|
||||
private DateTime $started;
|
||||
private DateTime $valid;
|
||||
private bool $hasCompleted;
|
||||
private DateTime $completed;
|
||||
|
||||
public function __construct(IDbResult $result) {
|
||||
$this->id = $result->getString(0);
|
||||
$this->userId = $result->getString(1);
|
||||
$this->remoteAddr = new IPAddress($result->getString(2));
|
||||
$this->country = $result->getString(3);
|
||||
$this->factorsRequired = $result->getInteger(4);
|
||||
$this->factorsDone = $result->getInteger(5);
|
||||
$this->started = DateTime::fromUnixTimeSeconds($result->getInteger(6));
|
||||
$this->valid = DateTime::fromUnixTimeSeconds($result->getInteger(7));
|
||||
$this->hasCompleted = !$result->isNull(8);
|
||||
$this->completed = DateTime::fromUnixTimeSeconds($result->getInteger(8));
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUserId(): string {
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getRemoteAddress(): IPAddress {
|
||||
return $this->remoteAddr;
|
||||
}
|
||||
|
||||
public function getCountryCode(): string {
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function getFactorsRequired(): int {
|
||||
return $this->factorsRequired;
|
||||
}
|
||||
|
||||
public function getFactorsDone(): int {
|
||||
return $this->factorsDone;
|
||||
}
|
||||
|
||||
public function getStartedTime(): DateTime {
|
||||
return $this->started;
|
||||
}
|
||||
|
||||
public function isValid(): bool {
|
||||
return DateTime::utcNow()->isLessThan($this->valid);
|
||||
}
|
||||
|
||||
public function getValidTime(): DateTime {
|
||||
return $this->valid;
|
||||
}
|
||||
|
||||
public function hasCompleted(): bool {
|
||||
return $this->hasCompleted;
|
||||
}
|
||||
|
||||
public function getCompletedTime(): DateTime {
|
||||
return $this->completed;
|
||||
}
|
||||
}
|
19
src/Auth/IAuthLogin.php
Normal file
19
src/Auth/IAuthLogin.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
namespace Hanyuu\Auth;
|
||||
|
||||
use Index\DateTime;
|
||||
use Index\Net\IPAddress;
|
||||
|
||||
interface IAuthLogin {
|
||||
public function getId(): string;
|
||||
public function getUserId(): string;
|
||||
public function getRemoteAddress(): IPAddress;
|
||||
public function getCountryCode(): string;
|
||||
public function getFactorsRequired(): int;
|
||||
public function getFactorsDone(): int;
|
||||
public function getStartedTime(): DateTime;
|
||||
public function isValid(): bool;
|
||||
public function getValidTime(): DateTime;
|
||||
public function hasCompleted(): bool;
|
||||
public function getCompletedTime(): DateTime;
|
||||
}
|
|
@ -8,6 +8,9 @@ use Index\Data\Migration\FsDbMigrationRepo;
|
|||
use Index\Http\HttpFx;
|
||||
use Index\Http\HttpRequest;
|
||||
use Index\Routing\IRouter;
|
||||
use Hanyuu\Auth\Auth;
|
||||
use Hanyuu\Auth\AuthRoutes;
|
||||
use Hanyuu\Auth\Db\DbAuth;
|
||||
use Hanyuu\Config\IConfig;
|
||||
use Hanyuu\Templating\TemplateContext;
|
||||
use Hanyuu\Users\IUsers;
|
||||
|
@ -17,6 +20,7 @@ class HanyuuContext {
|
|||
private IConfig $config;
|
||||
private IDbConnection $dbConn;
|
||||
private IUsers $users;
|
||||
private Auth $auth;
|
||||
private ?TemplateContext $tpl = null;
|
||||
|
||||
public function __construct(IConfig $config, IDbConnection $dbConn) {
|
||||
|
@ -63,6 +67,14 @@ class HanyuuContext {
|
|||
return $this->getTemplating()->render(...$args);
|
||||
}
|
||||
|
||||
public function setUpAuth(): void {
|
||||
$this->auth = new DbAuth($this->dbConn);
|
||||
}
|
||||
|
||||
public function getAuth(): Auth {
|
||||
return $this->auth;
|
||||
}
|
||||
|
||||
public function getRouter(): IRouter {
|
||||
return $this->router->getRouter();
|
||||
}
|
||||
|
@ -107,25 +119,8 @@ class HanyuuContext {
|
|||
return 503;
|
||||
});
|
||||
|
||||
if(!HAU_DEBUG)
|
||||
return;
|
||||
$this->router->get('/coffee', function() { return 418; });
|
||||
|
||||
$this->router->get('/test', function() {
|
||||
$users = $this->getUsers();
|
||||
$userInfo = $users->getUserInfoByName('flAsH');
|
||||
|
||||
$ffa = $users->getUserFirstFactorAuthInfos($userInfo);
|
||||
$tfa = $users->getUserSecondFactorAuthInfos($userInfo);
|
||||
|
||||
$pwdInfo = $users->getUserPasswordInfo($userInfo);
|
||||
|
||||
$totpInfo = $users->getUserTOTPInfo($userInfo);
|
||||
$totpGen = $totpInfo->createGenerator();
|
||||
|
||||
$emailInfos = $users->getUserEMailInfos($userInfo);
|
||||
|
||||
$backupInfos = $users->getUserBackupInfos($userInfo);
|
||||
|
||||
});
|
||||
new AuthRoutes($this->router, $this, $this->config->scopeTo('auth'));
|
||||
}
|
||||
}
|
||||
|
|
27
src/StatementCache.php
Normal file
27
src/StatementCache.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
namespace Hanyuu;
|
||||
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Data\IDbStatement;
|
||||
|
||||
class StatementCache {
|
||||
private array $statements = [];
|
||||
|
||||
public function __construct(
|
||||
private IDbConnection $conn
|
||||
) {}
|
||||
|
||||
public function getStatement(string $name, callable $query): IDbStatement {
|
||||
if(array_key_exists($name, $this->statements)) {
|
||||
$stmt = $this->statements[$name];
|
||||
$stmt->reset();
|
||||
} else
|
||||
$this->statements[$name] = $stmt = $this->conn->prepare($query());
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function clearStatement(string $name): void {
|
||||
unset($this->statements[$name]);
|
||||
}
|
||||
}
|
|
@ -32,4 +32,14 @@ class DbUserTOTPInfo implements IUserTOTPInfo {
|
|||
public function createGenerator(): TOTPGenerator {
|
||||
return new TOTPGenerator($this->secretKey);
|
||||
}
|
||||
|
||||
public function generateValidCodes(int $offset = 0, int $timeStamp = -1): array {
|
||||
$generator = $this->createGenerator();
|
||||
|
||||
return [
|
||||
$generator->generate(-1 + $offset, $timeStamp),
|
||||
$generator->generate($offset, $timeStamp),
|
||||
$generator->generate(1 + $offset, $timeStamp),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,14 +6,18 @@ use Index\Data\IDbConnection;
|
|||
use Index\Data\IDbStatement;
|
||||
use Index\Data\IDbResult;
|
||||
use Index\Data\DbType;
|
||||
use Hanyuu\StatementCache;
|
||||
use Hanyuu\Auth\IAuthMethod;
|
||||
use Hanyuu\Auth\Auth;
|
||||
use Hanyuu\Users\IUsers;
|
||||
use Hanyuu\Users\IUserInfo;
|
||||
use Hanyuu\Users\IUserPasswordInfo;
|
||||
use Hanyuu\Users\IUserTOTPInfo;
|
||||
use Hanyuu\Users\UserNotFoundException;
|
||||
|
||||
class DbUsers implements IUsers {
|
||||
private const USERS_TABLE = 'hau_users';
|
||||
private const AUTH_TABLE = 'hau_users_auth';
|
||||
private const TFA_TABLE = 'hau_users_tfa';
|
||||
private const BACKUP_TABLE = 'hau_users_backup';
|
||||
private const EMAILS_TABLE = 'hau_users_emails';
|
||||
private const PASSWORDS_TABLE = 'hau_users_passwords';
|
||||
|
@ -23,8 +27,8 @@ class DbUsers implements IUsers {
|
|||
'user_id', 'user_name', 'user_country', 'user_colour', 'user_super', 'user_time_zone',
|
||||
'UNIX_TIMESTAMP(user_created)', 'UNIX_TIMESTAMP(user_updated)', 'UNIX_TIMESTAMP(user_deleted)',
|
||||
];
|
||||
private const AUTH_FIELDS = [
|
||||
'user_id', 'user_auth_type', 'UNIX_TIMESTAMP(user_auth_enabled)',
|
||||
private const TFA_FIELDS = [
|
||||
'user_id', 'user_tfa_type', 'UNIX_TIMESTAMP(user_tfa_enabled)',
|
||||
];
|
||||
private const PASSWORDS_FIELDS = [
|
||||
'user_id', 'user_password_hash', 'UNIX_TIMESTAMP(user_password_changed)',
|
||||
|
@ -39,32 +43,23 @@ class DbUsers implements IUsers {
|
|||
'user_id', 'user_backup_code', 'UNIX_TIMESTAMP(user_backup_created)', 'UNIX_TIMESTAMP(user_backup_used)',
|
||||
];
|
||||
|
||||
private const FIRST_FACTOR_AUTH = ['pwd'];
|
||||
private const SECOND_FACTOR_AUTH = ['totp'];
|
||||
private StatementCache $stmts;
|
||||
|
||||
public function __construct(
|
||||
private IDbConnection $conn
|
||||
) {}
|
||||
|
||||
private array $statements = [];
|
||||
|
||||
private function getStatement(string $name, callable $query): IDbStatement {
|
||||
if(array_key_exists($name, $this->statements))
|
||||
return $this->statements[$name];
|
||||
return $this->statements[$name] = $this->conn->prepare($query());
|
||||
) {
|
||||
$this->stmts = new StatementCache($conn);
|
||||
}
|
||||
|
||||
|
||||
private function fetchUserInfoSingle(IDbResult $result, string $exceptionText): IUserInfo {
|
||||
if(!$result->next())
|
||||
throw new RuntimeException($exceptionText);
|
||||
throw new UserNotFoundException($exceptionText);
|
||||
|
||||
return new DbUserInfo($result);
|
||||
}
|
||||
|
||||
public function getUserInfoById(string $userId): IUserInfo {
|
||||
$stmt = $this->getStatement('get info by id', fn() => ('SELECT ' . implode(',', self::USERS_FIELDS) . ' FROM ' . self::USERS_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->reset();
|
||||
$stmt = $this->stmts->getStatement('get info by id', fn() => ('SELECT ' . implode(',', self::USERS_FIELDS) . ' FROM ' . self::USERS_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userId, DbType::STRING);
|
||||
$stmt->execute();
|
||||
|
||||
|
@ -72,8 +67,7 @@ class DbUsers implements IUsers {
|
|||
}
|
||||
|
||||
public function getUserInfoByName(string $userName): IUserInfo {
|
||||
$stmt = $this->getStatement('get info by name', fn() => ('SELECT ' . implode(',', self::USERS_FIELDS) . ' FROM ' . self::USERS_TABLE . ' WHERE user_name = ?'));
|
||||
$stmt->reset();
|
||||
$stmt = $this->stmts->getStatement('get info by name', fn() => ('SELECT ' . implode(',', self::USERS_FIELDS) . ' FROM ' . self::USERS_TABLE . ' WHERE user_name = ?'));
|
||||
$stmt->addParameter(1, $userName, DbType::STRING);
|
||||
$stmt->execute();
|
||||
|
||||
|
@ -81,7 +75,21 @@ class DbUsers implements IUsers {
|
|||
}
|
||||
|
||||
|
||||
private function fetchAuthInfoMultiple(IDbResult $result): array {
|
||||
public function countUserTFAMethods(IUserInfo $userInfo): int {
|
||||
$stmt = $this->stmts->getStatement('count user tfa methods', fn() => ('SELECT COUNT(*) FROM ' . self::TFA_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
||||
return $result->next() ? $result->getInteger(0) : 0;
|
||||
}
|
||||
|
||||
public function getUserTFAMethods(IUserInfo $userInfo): array {
|
||||
$stmt = $this->stmts->getStatement('get user tfa methods', fn() => ('SELECT ' . implode(',', self::TFA_FIELDS) . ' FROM ' . self::TFA_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
|
||||
$result = $stmt->getResult();
|
||||
$array = [];
|
||||
|
||||
while($result->next())
|
||||
|
@ -90,28 +98,19 @@ class DbUsers implements IUsers {
|
|||
return $array;
|
||||
}
|
||||
|
||||
public function getUserFirstFactorAuthInfos(IUserInfo $userInfo): array {
|
||||
$stmt = $this->getStatement('get auth first', fn() => ('SELECT ' . implode(',', self::AUTH_FIELDS) . ' FROM ' . self::AUTH_TABLE . ' WHERE user_id = ? AND user_auth_type IN ("' . implode('", "', self::FIRST_FACTOR_AUTH) . '")'));
|
||||
$stmt->reset();
|
||||
public function checkUserTFAMethod(IUserInfo $userInfo, IAuthMethod $method): bool {
|
||||
$stmt = $this->stmts->getStatement('check user auth method', fn() => ('SELECT COUNT(*) FROM ' . self::TFA_TABLE . ' WHERE user_id = ? AND user_tfa_type = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->addParameter(2, $method->getName(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
||||
return $this->fetchAuthInfoMultiple($stmt->getResult());
|
||||
}
|
||||
|
||||
public function getUserSecondFactorAuthInfos(IUserInfo $userInfo): array {
|
||||
$stmt = $this->getStatement('get auth second', fn() => ('SELECT ' . implode(',', self::AUTH_FIELDS) . ' FROM ' . self::AUTH_TABLE . ' WHERE user_id = ? AND user_auth_type IN ("' . implode('", "', self::SECOND_FACTOR_AUTH) . '")'));
|
||||
$stmt->reset();
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->fetchAuthInfoMultiple($stmt->getResult());
|
||||
return $result->next() ? $result->getInteger(0) !== 0 : false;
|
||||
}
|
||||
|
||||
|
||||
public function getUserPasswordInfo(IUserInfo $userInfo): IUserPasswordInfo {
|
||||
$stmt = $this->getStatement('get pwd', fn() => ('SELECT ' . implode(',', self::PASSWORDS_FIELDS) . ' FROM ' . self::PASSWORDS_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->reset();
|
||||
$stmt = $this->stmts->getStatement('get pwd', fn() => ('SELECT ' . implode(',', self::PASSWORDS_FIELDS) . ' FROM ' . self::PASSWORDS_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
@ -124,8 +123,7 @@ class DbUsers implements IUsers {
|
|||
|
||||
|
||||
public function getUserTOTPInfo(IUserInfo $userInfo): IUserTOTPInfo {
|
||||
$stmt = $this->getStatement('get totp', fn() => ('SELECT ' . implode(',', self::TOTP_FIELDS) . ' FROM ' . self::TOTP_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->reset();
|
||||
$stmt = $this->stmts->getStatement('get totp', fn() => ('SELECT ' . implode(',', self::TOTP_FIELDS) . ' FROM ' . self::TOTP_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
@ -138,8 +136,7 @@ class DbUsers implements IUsers {
|
|||
|
||||
|
||||
public function getUserEMailInfos(IUserInfo $userInfo): array {
|
||||
$stmt = $this->getStatement('get emails', fn() => ('SELECT ' . implode(',', self::EMAILS_FIELDS) . ' FROM ' . self::EMAILS_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->reset();
|
||||
$stmt = $this->stmts->getStatement('get emails', fn() => ('SELECT ' . implode(',', self::EMAILS_FIELDS) . ' FROM ' . self::EMAILS_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
@ -154,8 +151,7 @@ class DbUsers implements IUsers {
|
|||
|
||||
|
||||
public function getUserBackupInfos(IUserInfo $userInfo): array {
|
||||
$stmt = $this->getStatement('get backups', fn() => ('SELECT ' . implode(',', self::BACKUP_FIELDS) . ' FROM ' . self::BACKUP_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->reset();
|
||||
$stmt = $this->stmts->getStatement('get backups', fn() => ('SELECT ' . implode(',', self::BACKUP_FIELDS) . ' FROM ' . self::BACKUP_TABLE . ' WHERE user_id = ?'));
|
||||
$stmt->addParameter(1, $userInfo->getId(), DbType::STRING);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
|
|
@ -9,4 +9,5 @@ interface IUserTOTPInfo {
|
|||
public function getSecretKey(): string;
|
||||
public function getChangedTime(): DateTime;
|
||||
public function createGenerator(): TOTPGenerator;
|
||||
public function generateValidCodes(int $offset = 0, int $timeStamp = -1): array;
|
||||
}
|
||||
|
|
6
src/Users/UserNotFoundException.php
Normal file
6
src/Users/UserNotFoundException.php
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
namespace Hanyuu\Users;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class UserNotFoundException extends RuntimeException {}
|
38
templates/auth/login-tfa.php
Normal file
38
templates/auth/login-tfa.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
$self->extends('auth/master');
|
||||
|
||||
$self->block('content', function() use ($self) {
|
||||
?>
|
||||
<form class="auth-login" method="post" action="/login/tfa">
|
||||
<div class="auth-avatar">
|
||||
<div class="auth-avatar-image">
|
||||
<img src="//flashii.net/assets/avatar/<?=$self->userInfo->getId();?>?res=200" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-fields">
|
||||
<label class="auth-input">
|
||||
<div class="auth-input-title">
|
||||
Username
|
||||
</div>
|
||||
<div class="auth-input-value">
|
||||
<input type="text" value="<?=$self->userInfo->getName();?>" readonly>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="auth-buttons">
|
||||
<?php if(in_array('totp', $self->authMethodNames)): ?>
|
||||
<a href="/login/tfa/totp" class="auth-button auth-button-primary">Authenticator app (TOTP)</a>
|
||||
<?php endif; ?>
|
||||
<?php if(in_array('u2f', $self->authMethodNames)): ?>
|
||||
<a href="/login/tfa/u2f" class="auth-button auth-button-primary">Security key (U2F)</a>
|
||||
<?php endif; ?>
|
||||
<?php if(in_array('backup', $self->authMethodNames)): ?>
|
||||
<a href="/login/tfa/backup" class="auth-button auth-button-primary">Backup code</a>
|
||||
<?php endif; ?>
|
||||
<a href="/login" class="auth-button">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
});
|
39
templates/auth/login-totp.php
Normal file
39
templates/auth/login-totp.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
$self->extends('auth/master');
|
||||
|
||||
$self->block('content', function() use ($self) {
|
||||
?>
|
||||
<form class="auth-login" method="post" action="/login/tfa/totp">
|
||||
<div class="auth-avatar">
|
||||
<div class="auth-avatar-image">
|
||||
<img src="//flashii.net/assets/avatar/<?=$self->userInfo->getId();?>?res=200" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-fields">
|
||||
<label class="auth-input">
|
||||
<div class="auth-input-title">
|
||||
Username
|
||||
</div>
|
||||
<div class="auth-input-value">
|
||||
<input type="text" value="<?=$self->userInfo->getName();?>" readonly>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="auth-input">
|
||||
<div class="auth-input-title">
|
||||
Authenticator code
|
||||
</div>
|
||||
<div class="auth-input-value">
|
||||
<input type="text" name="code" value="" maxlength="6" inputmode="numeric" placeholder="------">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="auth-buttons">
|
||||
<input type="submit" value="Next" class="auth-button auth-button-primary">
|
||||
<a href="/login/tfa" class="auth-button">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
});
|
52
templates/auth/login.php
Normal file
52
templates/auth/login.php
Normal file
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
$self->extends('auth/master');
|
||||
|
||||
$self->registerSuffix = '';
|
||||
$self->forgotSuffix = '';
|
||||
if(!empty($self->userName)) {
|
||||
$suffix = '?username=' . rawurlencode($self->userName);
|
||||
|
||||
if($self->errorId === 'user_not_found')
|
||||
$self->registerSuffix = $suffix;
|
||||
else
|
||||
$self->forgotSuffix = $suffix;
|
||||
}
|
||||
|
||||
$self->block('content', function() use ($self) {
|
||||
?>
|
||||
<form class="auth-login" method="post" action="/login">
|
||||
<div class="auth-avatar">
|
||||
<div class="auth-avatar-image">
|
||||
<img src="//flashii.net/assets/avatar/0?res=200" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-fields">
|
||||
<label class="auth-input">
|
||||
<div class="auth-input-title">
|
||||
Username
|
||||
</div>
|
||||
<div class="auth-input-value">
|
||||
<input type="text" name="username" value="<?=$self->x($self->userName);?>" placeholder="example123">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="auth-input">
|
||||
<div class="auth-input-title">
|
||||
Password
|
||||
</div>
|
||||
<div class="auth-input-value">
|
||||
<input type="password" name="password" value="">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="auth-buttons">
|
||||
<input type="submit" value="Next" class="auth-button auth-button-primary">
|
||||
<a href="/forgot-username<?=$self->forgotSuffix;?>" class="auth-button">Forgot username?</a>
|
||||
<a href="/forgot-password<?=$self->forgotSuffix;?>" class="auth-button">Forgot password?</a>
|
||||
<a href="/register<?=$self->registerSuffix;?>" class="auth-button">Create account</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
});
|
13
templates/auth/master.php
Normal file
13
templates/auth/master.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
$self->extends('master');
|
||||
|
||||
$self->block('body', function() use ($self) {
|
||||
?>
|
||||
<div class="auth">
|
||||
<div class="auth-header">
|
||||
<?=$self->hau->getSiteName();?>
|
||||
</div>
|
||||
<?=$self->getBlock('content');?>
|
||||
</div>
|
||||
<?php
|
||||
});
|
|
@ -3,7 +3,9 @@ $self->extends('master');
|
|||
|
||||
$self->block('body', function($self) {
|
||||
?>
|
||||
<h1><?=($self->http_error_title ?? 'Unknown Error');?></h1>
|
||||
<p><?=($self->http_error_desc ?? 'No additional information is available.');?></p>
|
||||
<div class="http-err">
|
||||
<h1 class="http-err-title"><?=($self->http_error_title ?? 'Unknown Error');?></h1>
|
||||
<p class="http-err-paragraph"><?=($self->http_error_desc ?? 'No additional information is available.');?></p>
|
||||
</div>
|
||||
<?php
|
||||
});
|
||||
|
|
|
@ -4,8 +4,10 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title><?=$self->hau->getSiteName();?></title>
|
||||
<link href="/assets/hanyuu.css" type="text/css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<?=$self->getBlock('body');?>
|
||||
<script src="/assets/hanyuu.js" type="text/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
0
tools/migrate
Executable file → Normal file
0
tools/migrate
Executable file → Normal file
0
tools/new-migration
Executable file → Normal file
0
tools/new-migration
Executable file → Normal file
Loading…
Reference in a new issue