Finalizing auth migration
This commit is contained in:
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
152
src/Libs/IpUtils.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user