Finalizing auth migration

This commit is contained in:
arabcoders
2025-05-15 18:20:37 +03:00
parent 8893b9cd6a
commit b0663356e2
12 changed files with 557 additions and 122 deletions

View File

@@ -18,13 +18,13 @@ use Monolog\Level;
return (function () {
$inContainer = inContainer();
$progressTimeCheck = fn(int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
$progressTimeCheck = fn (int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
$config = [
'name' => 'WatchState',
'version' => '$(version_via_ci)',
'tz' => env('WS_TZ', env('TZ', 'UTC')),
'path' => fixPath(env('WS_DATA_PATH', fn() => $inContainer ? '/config' : __DIR__ . '/../var')),
'path' => fixPath(env('WS_DATA_PATH', fn () => $inContainer ? '/config' : __DIR__ . '/../var')),
'logs' => [
'context' => (bool)env('WS_LOGS_CONTEXT', false),
'prune' => [
@@ -44,7 +44,7 @@ return (function () {
'encode' => JSON_INVALID_UTF8_IGNORE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
'headers' => [
'Content-Type' => 'application/json',
'X-Application-Version' => fn() => getAppVersion(),
'X-Application-Version' => fn () => getAppVersion(),
'Access-Control-Allow-Origin' => '*',
],
],
@@ -70,6 +70,14 @@ return (function () {
'trust' => [
'proxy' => (bool)env('WS_TRUST_PROXY', false),
'header' => (string)env('WS_TRUST_HEADER', 'X-Forwarded-For'),
'local' => (bool)env('WS_TRUST_LOCAL', false),
'localnet' => [
'192.168.0.0/16', // RFC-1918 A-block.
'127.0.0.1/32', // localhost IPv4
'10.0.0.0/8', // RFC-1918 C-block.
'::1/128', // localhost IPv6
'172.16.0.0/12' // RFC-1918 B-block.
],
],
'sync' => [
'progress' => (bool)env('WS_SYNC_PROGRESS', true),
@@ -154,14 +162,14 @@ return (function () {
$config['profiler'] = [
'save' => (bool)env('WS_PROFILER_SAVE', true),
'path' => env('WS_PROFILER_PATH', fn() => ag($config, 'tmpDir') . '/profiler'),
'path' => env('WS_PROFILER_PATH', fn () => ag($config, 'tmpDir') . '/profiler'),
'collector' => env('WS_PROFILER_COLLECTOR', null),
];
$config['cache'] = [
'prefix' => env('WS_CACHE_PREFIX', null),
'url' => env('WS_CACHE_URL', 'redis://127.0.0.1:6379'),
'path' => env('WS_CACHE_PATH', fn() => ag($config, 'tmpDir') . '/cache'),
'path' => env('WS_CACHE_PATH', fn () => ag($config, 'tmpDir') . '/cache'),
];
$config['logger'] = [
@@ -345,11 +353,12 @@ return (function () {
$config['password'] = [
'prefix' => 'ws_hash@:',
'algo' => PASSWORD_BCRYPT,
'options' => ['cost' => 10],
'options' => ['cost' => 12],
];
$config['system'] = [
'user' => env('WS_SYSTEM_USER', null),
'secret' => env('WS_SYSTEM_SECRET', null),
'password' => env('WS_SYSTEM_PASSWORD', null),
];

View File

@@ -28,6 +28,17 @@ return (function () {
'key' => 'WS_TZ',
'description' => 'Set the Tool timezone.',
'type' => 'string',
'validate' => function (mixed $value): string {
if (is_numeric($value) || empty($value)) {
throw new ValidationException('Invalid timezone. Empty value.');
}
try {
return new DateTimeZone($value)->getName();
} catch (Throwable) {
throw new ValidationException("Invalid timezone '{$value}'.");
}
},
],
[
'key' => 'WS_LOGS_CONTEXT',
@@ -54,6 +65,12 @@ return (function () {
'description' => 'Trust the IP from the WS_TRUST_HEADER header.',
'type' => 'bool',
],
[
'key' => 'WS_TRUST_LOCAL',
'description' => 'Bypass the authentication layer for local IP Addresses for WebUI.',
'type' => 'bool',
'danger' => true,
],
[
'key' => 'WS_TRUST_HEADER',
'description' => 'The header which contains the true user IP.',
@@ -85,6 +102,7 @@ return (function () {
'description' => 'The API key to allow access to the API.',
'type' => 'string',
'mask' => true,
'protected' => true,
],
[
'key' => 'WS_LOGS_PRUNE_AFTER',
@@ -226,7 +244,7 @@ return (function () {
],
[
'key' => 'WS_SYSTEM_USER',
'description' => 'The login user name',
'description' => 'The login user name.',
'type' => 'string',
'validate' => function (mixed $value): string {
if (!is_numeric($value) && empty($value)) {
@@ -241,7 +259,7 @@ return (function () {
return $value;
},
'mask' => true,
'hidden' => true,
'protected' => true,
],
[
'key' => 'WS_SYSTEM_PASSWORD',
@@ -267,7 +285,29 @@ return (function () {
return $prefix . $hash;
},
'mask' => true,
'hidden' => true,
'protected' => true,
],
[
'key' => 'WS_SYSTEM_SECRET',
'description' => 'The secret key which is used to sign sucessful auth requests.',
'type' => 'string',
'validate' => function (mixed $value): string {
if (empty($value)) {
throw new ValidationException('Invalid secret. Empty value.');
}
if (false === is_string($value)) {
throw new ValidationException('Invalid secret. Must be a string.');
}
if (strlen($value) < 32) {
throw new ValidationException('Invalid secret. Must be at least 32 characters long.');
}
return $value;
},
'mask' => true,
'protected' => true,
],
];

View File

@@ -1,13 +1,67 @@
<template>
<div class="columns is-multiline mb-2">
<div class="column">
<div class="column is-6 is-12-mobile">
<div class="card">
<header class="card-header">
<p class="card-header-title">Password & Sessions</p>
<span class="card-header-icon"><span class="icon"><i class="fas fa-cog" /></span></span>
</header>
<div class="card-content">
<div class="field">
<label class="label" for="current_password">Current password</label>
<div class="control has-icons-left">
<input id="current_password" type="password" class="input" v-model="user.current_password"
:disabled="isLoading" placeholder="Current password" required>
<span class="icon is-left"><i class="fa fa-lock" /></span>
</div>
</div>
<div class="field">
<label class="label" for="new_password">New Password</label>
<div class="control has-icons-left">
<input id="new_password" type="password" class="input" v-model="user.new_password" :disabled="isLoading"
placeholder="New password" required>
<span class="icon is-left"><i class="fa fa-lock" /></span>
</div>
</div>
<div class="field">
<label class="label" for="new_password_confirm">Confirm New Password</label>
<div class="control has-icons-left">
<input id="new_password_confirm" type="password" class="input" v-model="user.new_password_confirm"
:disabled="isLoading" placeholder="Confirm new password" required>
<span class="icon is-left"><i class="fa fa-lock" /></span>
</div>
</div>
<div class="field is-grouped">
<div class="control is-expanded">
<button type="button" class="button is-fullwidth is-primary" @click="change_password"
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fa-solid fa-key" /></span>
<span>Change Password</span>
</button>
</div>
<div class="control is-expanded">
<button type="button" class="button is-fullwidth is-danger" @click="invalidate_sessions"
:disabled="isLoading">
<span class="icon"><i class="fa-solid fa-user-slash" /></span>
<span>Invalidate Sessions</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="card">
<header class="card-header">
<p class="card-header-title">
WebUI Look & Feel
</p>
<span class="card-header-icon">
<span class="icon"><i class="fas fa-paint-brush"/></span>
<span class="icon"><i class="fas fa-paint-brush" /></span>
</span>
</header>
<div class="card-content">
@@ -25,7 +79,7 @@
</label>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info"/></span>
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the color scheme for the WebUI.</span>
</p>
</div>
@@ -44,8 +98,8 @@
Background Visibility: (<code>{{ bg_opacity }}</code>)
</label>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.60"
max="1.00" step="0.05">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.60" max="1.00"
step="0.05">
<p class="help">How visible the background image should be.</p>
</div>
</div>
@@ -56,9 +110,80 @@
</template>
<script setup>
import {useStorage} from '@vueuse/core'
import { useStorage } from '@vueuse/core'
const webui_theme = useStorage('theme', 'auto')
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const user = ref({
current_password: '',
new_password: '',
new_password_confirm: ''
})
const isLoading = ref(false)
const change_password = async () => {
if (!user.value.current_password || !user.value.new_password || !user.value.new_password_confirm) {
notification('Error', 'Error', 'All fields are required.', 2000)
return
}
if (user.value.new_password !== user.value.new_password_confirm) {
notification('Error', 'Error', 'New passwords do not match.', 2000)
return
}
try {
isLoading.value = true
const response = await request('/system/auth/change_password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
new_password: user.value.new_password,
current_password: user.value.current_password,
})
})
const json = await parse_api_response(response)
if (200 !== response.status) {
notification('Error', 'Error', json.error.message, 2000)
return
}
notification('Success', 'Success', json.info.message)
user.value = {
current_password: '',
new_password: '',
new_password_confirm: ''
}
return
} finally {
isLoading.value = false
}
}
const invalidate_sessions = async () => {
if (!confirm('Are you sure you want to invalidate all sessions?')) {
return
}
try {
isLoading.value = true
const response = await request('/system/auth/sessions', { method: 'DELETE' })
const json = await parse_api_response(response)
if (200 !== response.status) {
notification('Error', 'Error', json.error.message, 2000)
return
}
notification('Success', 'Success', json.info.message)
const token = useStorage('token', null)
token.value = null
await navigateTo('/auth')
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -193,8 +193,7 @@
</div>
<div class="navbar-item">
<button class="button is-dark" @click="showSettings = !showSettings"
v-tooltip="'WebUI Settings'">
<button class="button is-dark" @click="showSettings = !showSettings" v-tooltip="'Settings'">
<span class="icon"><i class="fas fa-cog"/></span>
</button>
</div>

View File

@@ -6,19 +6,48 @@
<div class="columns is-centered">
<div class="column is-6-tablet is-6-desktop is-4-widescreen">
<div class="box" v-if="error">
<span class="icon"><i class="fa fa-info"/></span>
<span class="icon"><i class="fa fa-info" /></span>
<span class="has-text-danger">{{ error }}</span>
</div>
<form method="post" @submit.prevent="formValidate()" class="box">
<div class="box" v-if="forgot_password">
<div class="content">
<h4>
<span class="icon"><i class="fa fa-lock" /></span>
<span>How to Reset system login</span>
</h4>
<p>
To reset your system password, you need to run the following command from your docker host
</p>
<p>
<code style="min-height: 50px;" class="is-block p-2">{{ reset_cmd }}</code>
</p>
<p>
<a @click.prevent="copyText(reset_cmd)">
<span class="icon"><i class="fa fa-copy" /></span>
<span>Copy command</span>
</a>
</p>
</div>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-fullwidth is-info is-dark" @click="forgot_password = false">
<span class="icon"><i class="fa fa-arrow-left" /></span>
<span>Back to login</span>
</button>
</div>
</div>
</div>
<form method="post" @submit.prevent="formValidate()" class="box" v-if="!forgot_password">
<div class="field">
<label for="user-id" class="label">
{{ signup ? 'Create an account' : 'Login' }}
</label>
<div class="control has-icons-left">
<input id="user-id" type="text" placeholder="Username" class="input" required
autocomplete="username" name="username" v-model="user.username" autofocus>
<span class="icon is-left"><i class="fa fa-user"/></span>
autocomplete="username" name="username" v-model="user.username" autofocus>
<span class="icon is-left"><i class="fa fa-user" /></span>
</div>
</div>
<div class="field">
@@ -27,17 +56,14 @@
<div class="field">
<div class="field has-addons">
<div class="control is-expanded has-icons-left">
<input class="input" id="user-password" v-model="user.password"
required placeholder="Password"
:type="false === form_expose ? 'password' : 'text'">
<span class="icon is-left"><i class="fa fa-lock"/></span>
<input class="input" id="user-password" v-model="user.password" required
placeholder="Password" :type="false === form_expose ? 'password' : 'text'">
<span class="icon is-left"><i class="fa fa-lock" /></span>
</div>
<div class="control">
<button type="button" class="button is-primary"
@click="form_expose = !form_expose">
<span class="icon" v-if="!form_expose"><i
class="fas fa-eye"/></span>
<span class="icon" v-else><i class="fas fa-eye-slash"/></span>
<button type="button" class="button is-secondary" @click="form_expose = !form_expose">
<span class="icon" v-if="!form_expose"><i class="fas fa-eye" /></span>
<span class="icon" v-else><i class="fas fa-eye-slash" /></span>
</button>
</div>
</div>
@@ -46,12 +72,19 @@
</div>
<div class="field">
<button type="submit" class="button is-fullwidth is-dark is-light">
<span class="icon"><i class="fa fa-sign-in"/></span>
<span class="icon"><i class="fa fa-sign-in" /></span>
<span>
{{ signup ? 'Signup' : 'Login' }}
</span>
</button>
</div>
<div class="field" v-if="!signup">
<button type="button" class="button is-fullwidth is-info is-dark"
@click="forgot_password = !forgot_password">
<span class="icon"><i class="fa fa-lock" /></span>
<span>Forgot your user or password?</span>
</button>
</div>
</form>
</div>
</div>
@@ -62,21 +95,34 @@
</template>
<script setup>
import {onMounted, ref} from 'vue'
import {useAuthStore} from '~/store/auth'
import { onMounted, ref } from 'vue'
import { useAuthStore } from '~/store/auth'
definePageMeta({name: "auth", layout: 'guest'})
useHead({title: 'WatchState: Auth'})
definePageMeta({ name: "auth", layout: 'guest' })
useHead({ title: 'WatchState: Auth' })
const error = ref('')
const form_expose = ref(false)
const signup = ref(false)
const auth = useAuthStore()
const forgot_password = ref(false)
const user = ref({username: '', password: ''})
const user = ref({ username: '', password: '' })
const reset_cmd = ref('docker exec -ti -- watchstate console system:resetpassword')
onMounted(async () => signup.value = false === (await auth.has_user()))
onMounted(async () => {
signup.value = false === (await auth.has_user())
if (auth.authenticated) {
return await navigateTo('/')
}
})
watch(forgot_password, async newValue => {
if (false === newValue) {
signup.value = false === (await auth.has_user())
}
})
const formValidate = async () => {
if (user.value.username.length < 1) {

View File

@@ -9,7 +9,18 @@ export const useAuthStore = defineStore('auth', () => {
const actions = {
async has_user() {
const req = await request('/system/auth/has_user')
return 200 === req.status
const status = 200 === req.status
if (req.ok && req) {
const json = await parse_api_response(req)
if (json?.token && json?.auto_login) {
const token = useStorage('token', null);
this.token = json.token
token.value = json.token;
this.authenticated = true
}
}
return status
},
async signup(username, password) {
if (!username || !password) {

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Delete;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Post;
use App\Libs\Attributes\Route\Put;
use App\Libs\Config;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Method;
@@ -13,6 +15,7 @@ use App\Libs\Enums\Http\Status;
use App\Libs\Middlewares\AuthorizationMiddleware;
use App\Libs\TokenUtil;
use App\Libs\Traits\APITraits;
use App\Libs\IpUtils;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
@@ -30,16 +33,43 @@ final class Auth
}
#[Get(self::URL . '/has_user[/]', name: 'system.auth.has_user')]
public function has_user(): iResponse
public function has_user(iRequest $request): iResponse
{
$user = Config::get('system.user');
$password = Config::get('system.password');
return api_response(empty($user) || empty($password) ? Status::NO_CONTENT : Status::OK);
if (empty($user) || empty($password)) {
return api_response(Status::NO_CONTENT);
}
if (false === Config::get('trust.local', false)) {
return api_response(Status::OK);
}
$localAddress = getClientIp($request);
if (false === IpUtils::checkIp($localAddress, Config::get('trust.localnet', []))) {
return api_response(Status::OK);
}
$payload = [
'username' => Config::get('system.user'),
'iat' => time(),
'version' => getAppVersion(),
];
if (false === ($token = json_encode($payload))) {
return api_error('Failed to encode token.', Status::INTERNAL_SERVER_ERROR);
}
return api_response(Status::OK, [
'auto_login' => true,
'token' => TokenUtil::encode(TokenUtil::sign($token) . '.' . $token),
]);
}
#[Get(self::URL . '/user[/]', name: 'system.auth.user')]
public function me(iRequest $request): iResponse
public function user(iRequest $request): iResponse
{
$user = Config::get('system.user');
$pass = Config::get('system.password');
@@ -93,8 +123,8 @@ final class Auth
try {
$payload = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
$tokenUser = ag($payload, 'username', fn() => TokenUtil::generateSecret());
$systemUser = Config::get('system.user', fn() => TokenUtil::generateSecret());
$tokenUser = ag($payload, 'username', fn () => TokenUtil::generateSecret());
$systemUser = Config::get('system.user', fn () => TokenUtil::generateSecret());
if (false === hash_equals($systemUser, $tokenUser)) {
return api_error('Invalid token.', Status::UNAUTHORIZED);
@@ -110,7 +140,7 @@ final class Auth
}
#[Post(self::URL . '/signup[/]', name: 'system.auth.signup')]
public function do_signup(iRequest $request): iResponse
public function signup(iRequest $request): iResponse
{
$user = Config::get('system.user');
$pass = Config::get('system.password');
@@ -151,7 +181,7 @@ final class Auth
}
#[Post(self::URL . '/login[/]', name: 'system.auth.login')]
public function do_login(iRequest $request): iResponse
public function login(iRequest $request): iResponse
{
$data = DataUtil::fromRequest($request);
@@ -169,16 +199,22 @@ final class Auth
return api_error('System user or password is not configured.', Status::INTERNAL_SERVER_ERROR);
}
$system_pass = after($system_pass, Config::get('password.prefix', 'ws_hash@:'));
$validUser = true === hash_equals($username, $system_user);
$validPass = password_verify(
$password,
after($system_pass, Config::get('password.prefix', 'ws_hash@:'))
);
$validPass = password_verify($password, $system_pass);
if (false === $validUser || false === $validPass) {
return api_error('Invalid username or password.', Status::UNAUTHORIZED);
}
$algo = Config::get('password.algo', PASSWORD_BCRYPT);
$opts = Config::get('password.options', []);
if (true === password_needs_rehash($system_pass, $algo, $opts)) {
APIRequest(Method::POST, '/system/env/WS_SYSTEM_PASSWORD', ['value' => $password]);
}
$payload = [
'username' => $system_user,
'iat' => time(),
@@ -193,4 +229,47 @@ final class Auth
return api_response(Status::OK, ['token' => $token]);
}
#[Put(self::URL . '/change_password[/]', name: 'system.auth.change_password')]
public function change_password(iRequest $request): iResponse
{
$data = DataUtil::fromRequest($request);
$current_password = $data->get('current_password');
$new_password = $data->get('new_password');
if (empty($new_password) || empty($current_password)) {
return api_error('current_password and new_password fields are required.', Status::BAD_REQUEST);
}
$system_pass = Config::get('system.password');
if (empty($system_pass)) {
return api_error('System password is not configured.', Status::INTERNAL_SERVER_ERROR);
}
$system_pass = after($system_pass, Config::get('password.prefix', 'ws_hash@:'));
if (false === password_verify($current_password, $system_pass)) {
return api_error('Invalid current password.', Status::UNAUTHORIZED);
}
$repsonse = APIRequest(Method::POST, '/system/env/WS_SYSTEM_PASSWORD', ['value' => $new_password]);
if (Status::OK !== $repsonse->status) {
return api_error('Failed to set new password.', Status::INTERNAL_SERVER_ERROR);
}
return api_message('Password changed successfully.', Status::OK);
}
#[Delete(self::URL . '/sessions[/]', name: 'system.auth.sessions')]
public function invalidate_sessions(): iResponse
{
$response = APIRequest(Method::POST, '/system/env/WS_SYSTEM_SECRET', ['value' => TokenUtil::generateSecret()]);
if (Status::OK !== $response->status) {
return api_error('Failed to invalidate sessions.', Status::INTERNAL_SERVER_ERROR);
}
return api_message('Sessions invalidated successfully.', Status::OK);
}
}

View File

@@ -33,7 +33,7 @@ final class Env
continue;
}
if (true === (bool)ag($info, 'hidden', false)) {
if (true === (bool)ag($info, 'protected', false)) {
continue;
}
@@ -54,14 +54,14 @@ final class Env
if (array_key_exists('validate', $info)) {
unset($info['validate']);
}
if (true === (bool)ag($info, 'hidden', false)) {
if (true === (bool)ag($info, 'protected', false)) {
continue;
}
$list[] = $info;
}
if (true === (bool)$params->get('set', false)) {
$list = array_filter($list, fn($info) => $this->envFile->has($info['key']));
$list = array_filter($list, fn ($info) => $this->envFile->has($info['key']));
}
return api_response(Status::OK, [
@@ -71,7 +71,7 @@ final class Env
}
#[Get(self::URL . '/{key}[/]', name: 'system.env.view')]
public function envView(string $key): iResponse
public function envView(iRequest $request, string $key): iResponse
{
if (empty($key)) {
return api_error('Invalid value for key path parameter.', Status::BAD_REQUEST);
@@ -83,35 +83,42 @@ final class Env
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), Status::BAD_REQUEST);
}
$isProtected = true === (bool)ag($spec, 'protected', false);
if ($isProtected && false === $request->getAttribute('INTERNAL_REQUEST', false)) {
return api_error(r("Key '{key}' is not set.", ['key' => $key]), Status::NOT_FOUND);
}
if (false === $this->envFile->has($key)) {
return api_error(r("Key '{key}' is not set.", ['key' => $key]), Status::NOT_FOUND);
}
return api_response(Status::OK, [
'key' => $key,
'value' => $this->settype($spec, ag($spec, 'value', fn() => $this->envFile->get($key))),
'value' => $this->settype($spec, ag($spec, 'value', fn () => $this->envFile->get($key))),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),
'danger' => (bool)ag($spec, 'danger', false),
]);
}
#[Route(['POST', 'DELETE'], self::URL . '/{key}[/]', name: 'system.env.update')]
public function envUpdate(iRequest $request, array $args = []): iResponse
public function envUpdate(iRequest $request, string $key): iResponse
{
$key = strtoupper((string)ag($args, 'key', ''));
if (empty($key)) {
return api_error('Invalid value for key path parameter.', Status::BAD_REQUEST);
}
$key = strtoupper($key);
$spec = $this->getSpec($key);
if (empty($spec)) {
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), Status::BAD_REQUEST);
}
$isHidden = true === (bool)ag($spec, 'hidden', false);
if ($isHidden && false === $request->getAttribute('INTERNAL_REQUEST', false)) {
$isProtected = true === (bool)ag($spec, 'protected', false);
if ($isProtected && false === $request->getAttribute('INTERNAL_REQUEST', false)) {
return api_error(r("Key '{key}' is not set.", ['key' => $key]), Status::NOT_FOUND);
}
@@ -120,10 +127,11 @@ final class Env
return api_response(Status::OK, [
'key' => $key,
'value' => $this->setType($spec, ag($spec, 'value', fn() => $this->envFile->get($key))),
'value' => $this->setType($spec, ag($spec, 'value', fn () => $this->envFile->get($key))),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),
'danger' => (bool)ag($spec, 'danger', false),
]);
}
@@ -154,6 +162,7 @@ final class Env
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),
'danger' => (bool)ag($spec, 'danger', false),
]);
}

View File

@@ -7,24 +7,17 @@ namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\EnvFile;
use App\Libs\Enums\Http\Status;
use App\Libs\TokenUtil;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface as iOutput;
/**
* Class APIKeyCommand
*
* This class is a command that allows the user to generate a new API key or show the current one.
*/
#[Cli(command: self::ROUTE)]
final class APIKeyCommand extends Command
{
public const string ROUTE = 'system:apikey';
/**
* Configure the command.
*/
protected function configure(): void
{
$this->setName(self::ROUTE)
@@ -32,14 +25,6 @@ final class APIKeyCommand extends Command
->setDescription('Show current API key or generate a new one.');
}
/**
* Executes the command.
*
* @param iInput $input The input interface.
* @param iOutput $output The output interface.
*
* @return int The exit status code.
*/
protected function runCommand(iInput $input, iOutput $output): int
{
$regenerate = (bool)$input->getOption('regenerate');
@@ -47,7 +32,7 @@ final class APIKeyCommand extends Command
return $this->regenerate($output);
}
$output->writeln('<info>Current API key:</info>');
$output->writeln('<info>Current system API key:</info>');
$output->writeln('<comment>' . $apiKey . '</comment>');
return self::SUCCESS;
@@ -55,28 +40,19 @@ final class APIKeyCommand extends Command
private function regenerate(iOutput $output): int
{
try {
$apiKey = bin2hex(random_bytes(16));
} catch (\Exception $e) {
$output->writeln(
r('<error>Failed to generate a new API key. {error}</error>', ['error' => $e->getMessage()])
);
$apiKey = TokenUtil::generateSecret(16);
$response = APIRequest('POST', '/system/env/WS_API_KEY', [
'value' => $apiKey,
]);
if (Status::OK !== $response->status) {
$output->writeln(r("<error>Failed to set the new API key.</error>"));
return self::FAILURE;
}
$output->writeln('<info>The New API key is:</info>');
$output->writeln('<info>The New system API key:</info>');
$output->writeln('<comment>' . $apiKey . '</comment>');
if (null !== ($oldKey = Config::get('api.key'))) {
$output->writeln('<info>Old API key:</info>');
$output->writeln('<comment>' . $oldKey . '</comment>');
}
$envFile = new EnvFile(fixPath(Config::get('path') . '/config/.env'), true);
$envFile->set('WS_API_KEY', $apiKey)->persist();
$output->writeln(r("<info>API key has been added to '{file}'.</info>", ['file' => $envFile->file]));
return self::SUCCESS;
}
}

View File

@@ -6,14 +6,10 @@ namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Output\OutputInterface as iOutput;
/**
* Class ResetPasswordCommand
*/
#[Cli(command: self::ROUTE)]
final class ResetPasswordCommand extends Command
{
@@ -23,16 +19,15 @@ final class ResetPasswordCommand extends Command
{
$this->setName(self::ROUTE)
->setDescription('Reset the system user and password.')
->setHelp(
'Resets the current system user and password to allow you to signup again. It will also reset the secret key'
);
->setHelp('Resets the current system user and password.');
}
protected function runCommand(iInput $input, iOutput $output): int
{
$secret_file = Config::get('path') . '/config/.secret.key';
if (file_exists($secret_file)) {
unlink($secret_file);
$response = APIRequest('DELETE', '/system/env/WS_SYSTEM_SECRET');
if (Status::OK !== $response->status) {
$output->writeln(r("<error>Failed to reset the system secret key.</error>"));
return self::FAILURE;
}
$response = APIRequest('DELETE', '/system/env/WS_SYSTEM_USER');

152
src/Libs/IpUtils.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Libs;
use RuntimeException;
use function defined;
use function extension_loaded;
use function is_array;
use const FILTER_FLAG_IPV4;
use const FILTER_VALIDATE_IP;
final class IpUtils
{
private static array $checkedIps = [];
/**
* This class should not be instantiated.
*/
private function __construct()
{
}
/**
* Checks if an IPv4 or IPv6 address is contained in the list of given IPs or subnets.
*
* @param string $requestIp Request IP.
* @param string|array $ips List of IPs or subnets (can be a string if only a single one)
*
* @return bool
*/
public static function checkIp(string $requestIp, string|array $ips): bool
{
if (!is_array($ips)) {
$ips = [$ips];
}
$method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4';
return array_any($ips, fn($ip) => self::$method($requestIp, $ip));
}
/**
* Compares two IPv4 addresses.
* In case a subnet is given, it checks if it contains the request IP.
*
* @param string $ip IPv4 address or subnet in CIDR notation
*
* @return bool Whether the request IP matches the IP, or whether the request IP is within the CIDR subnet
*/
public static function checkIp4(string $requestIp, string $ip): bool
{
$cacheKey = $requestIp . '-' . $ip;
if (isset(self::$checkedIps[$cacheKey])) {
return self::$checkedIps[$cacheKey];
}
if (!filter_var($requestIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return self::$checkedIps[$cacheKey] = false;
}
if (str_contains($ip, '/')) {
[$address, $netmask] = explode('/', $ip, 2);
if ('0' === $netmask) {
return self::$checkedIps[$cacheKey] = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
}
if ($netmask < 0 || $netmask > 32) {
return self::$checkedIps[$cacheKey] = false;
}
} else {
$address = $ip;
$netmask = 32;
}
if (false === ip2long($address)) {
return self::$checkedIps[$cacheKey] = false;
}
return self::$checkedIps[$cacheKey] = 0 === substr_compare(
sprintf('%032b', ip2long($requestIp)),
sprintf('%032b', ip2long($address)),
0,
(int)$netmask
);
}
/**
* Compares two IPv6 addresses.
* In case a subnet is given, it checks if it contains the request IP.
*
* @param string $requestIp Request ip.
* @param string $ip IPv6 address or subnet in CIDR notation
*
* @return bool
*
* @throws RuntimeException When IPV6 support is not enabled
* @see https://github.com/dsp/v6tools
*
* @author David Soria Parra <dsp at php dot net>
*
*/
public static function checkIp6(string $requestIp, string $ip): bool
{
$cacheKey = $requestIp . '-' . $ip;
if (isset(self::$checkedIps[$cacheKey])) {
return self::$checkedIps[$cacheKey];
}
if (!((extension_loaded('sockets') && defined('AF_INET6')) || @inet_pton('::1'))) {
throw new RuntimeException(
'Unable to check Ipv6. Check that PHP was not compiled with option "disable-ipv6".'
);
}
if (str_contains($ip, '/')) {
[$address, $netmask] = explode('/', $ip, 2);
if ('0' === $netmask) {
return (bool)unpack('n*', @inet_pton($address));
}
if ($netmask < 1 || $netmask > 128) {
return self::$checkedIps[$cacheKey] = false;
}
} else {
$address = $ip;
$netmask = 128;
}
$bytesAddr = unpack('n*', @inet_pton($address));
$bytesTest = unpack('n*', @inet_pton($requestIp));
if (!$bytesAddr || !$bytesTest) {
return self::$checkedIps[$cacheKey] = false;
}
for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) {
$left = $netmask - 16 * ($i - 1);
$left = ($left <= 16) ? $left : 16;
$mask = ~(0xFFFF >> $left) & 0xFFFF;
if (($bytesAddr[$i] & $mask) != ($bytesTest[$i] & $mask)) {
return self::$checkedIps[$cacheKey] = false;
}
}
return self::$checkedIps[$cacheKey] = true;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Libs;
use App\Libs\Enums\Http\Status;
use App\Libs\Exceptions\RuntimeException;
final class TokenUtil
@@ -87,28 +88,21 @@ final class TokenUtil
*/
private static function getSecret(): string
{
static $_secretKey = null;
if (null !== $_secretKey) {
return $_secretKey;
if (null !== ($secret = Config::get('system.secret'))) {
return $secret;
}
$secretFile = fixPath(Config::get('path') . '/config/.secret.key');
$secret = static::generateSecret();
Config::save('system.secret', $secret);
if (false === file_exists($secretFile) || filesize($secretFile) < 32) {
$_secretKey = static::generateSecret();
$stream = Stream::make($secretFile, 'w');
$stream->write($_secretKey);
$stream->close();
return $_secretKey;
$response = APIRequest('POST', '/system/env/WS_SYSTEM_SECRET', [
'value' => $secret,
]);
if (Status::OK !== $response->status) {
throw new RuntimeException('Failed to set the new secret key.');
}
$_secretKey = Stream::make($secretFile, 'r')->getContents();
if (empty($_secretKey)) {
throw new RuntimeException('Failed to read secret key from file.');
}
return $_secretKey;
return $secret;
}
}