Merge commit '98b31298b086a00a47efa35c3e2b81a0cb8b3f37'
This commit is contained in:
parent
3b13030a73
commit
ee9b7d88d4
|
@ -5,6 +5,13 @@ release channel, you can take advantage of these new features and fixes.
|
|||
|
||||
## New Features/Changes
|
||||
|
||||
- **Passwordless Login with Passkeys**: You can now associate passkeys (also known as WebAuthn) with your account and
|
||||
then automatically log in with those passkeys any time. Popular passkeys include those provided by your operating
|
||||
system (i.e. Windows Hello or Apple's Safari passkey system) or may be provided by third-party software (like
|
||||
Bitwarden). They're a secure alternative to passwords and two-factor authentication. You can register multiple
|
||||
passkeys with a single account, and if you ever misplace your passkey, you can still log in with your regular e-mail
|
||||
address and password (and two-factor auth, if you've set one up).
|
||||
|
||||
## Code Quality/Technical Changes
|
||||
|
||||
- If you upload media to a folder and that folder is set to auto-assign to a playlist, the media will *instantly* be a
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"guzzlehttp/guzzle": "^7.0",
|
||||
"intervention/image": "^2.6",
|
||||
"james-heinrich/getid3": "v2.0.0-beta5",
|
||||
"lbuchs/webauthn": "^2.1",
|
||||
"league/csv": "^9.6",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/flysystem-sftp-v3": "^3.0",
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c5af35fc86c5a1ab7fe1dac7fff30c0c",
|
||||
"content-hash": "89af2db97e6bf2e6179935c6e2d32ea9",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
@ -2878,6 +2878,51 @@
|
|||
},
|
||||
"time": "2023-11-08T14:08:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lbuchs/webauthn",
|
||||
"version": "v2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lbuchs/WebAuthn.git",
|
||||
"reference": "1cc44fbd61d41aec55d0f21f386a1fdd7df1459a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/lbuchs/WebAuthn/zipball/1cc44fbd61d41aec55d0f21f386a1fdd7df1459a",
|
||||
"reference": "1cc44fbd61d41aec55d0f21f386a1fdd7df1459a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"lbuchs\\WebAuthn\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lukas Buchs",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple PHP WebAuthn (FIDO2) server library",
|
||||
"homepage": "https://github.com/lbuchs/webauthn",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"webauthn"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/lbuchs/WebAuthn/issues",
|
||||
"source": "https://github.com/lbuchs/WebAuthn/tree/v2.1.0"
|
||||
},
|
||||
"time": "2023-10-23T10:19:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/csv",
|
||||
"version": "9.11.0",
|
||||
|
|
|
@ -47,6 +47,31 @@ return static function (RouteCollectorProxy $group) {
|
|||
'/api-key/{id}',
|
||||
Controller\Api\Frontend\Account\ApiKeysController::class . ':deleteAction'
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/webauthn/register',
|
||||
Controller\Api\Frontend\Account\WebAuthn\GetRegistrationAction::class
|
||||
)->setName('api:frontend:webauthn:register');
|
||||
|
||||
$group->put(
|
||||
'/webauthn/register',
|
||||
Controller\Api\Frontend\Account\WebAuthn\PutRegistrationAction::class
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/passkeys',
|
||||
Controller\Api\Frontend\Account\PasskeysController::class . ':listAction'
|
||||
)->setName('api:frontend:passkeys');
|
||||
|
||||
$group->get(
|
||||
'/passkey/{id}',
|
||||
Controller\Api\Frontend\Account\PasskeysController::class . ':getAction'
|
||||
)->setName('api:frontend:passkey');
|
||||
|
||||
$group->delete(
|
||||
'/passkey/{id}',
|
||||
Controller\Api\Frontend\Account\PasskeysController::class . ':deleteAction'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -53,6 +53,11 @@ return static function (RouteCollectorProxy $app) {
|
|||
->setName('account:recover')
|
||||
->add(Middleware\EnableView::class);
|
||||
|
||||
$app->get('/login/webauthn', Controller\Frontend\Account\WebAuthn\GetValidationAction::class)
|
||||
->setName('account:webauthn');
|
||||
|
||||
$app->post('/login/webauthn', Controller\Frontend\Account\WebAuthn\PostValidationAction::class);
|
||||
|
||||
$app->group(
|
||||
'/setup',
|
||||
function (RouteCollectorProxy $group) {
|
||||
|
|
|
@ -1,245 +1,53 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2 class="outside-card-header mb-1">
|
||||
{{ $gettext('My Account') }}
|
||||
</h2>
|
||||
<h2 class="outside-card-header mb-1">
|
||||
{{ $gettext('My Account') }}
|
||||
</h2>
|
||||
|
||||
<div class="row row-of-cards">
|
||||
<div class="col-sm-12 col-md-6 col-lg-5">
|
||||
<card-page
|
||||
header-id="hdr_profile"
|
||||
:title="$gettext('Profile')"
|
||||
>
|
||||
<loading :loading="userLoading">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
v-if="user.avatar.url_128"
|
||||
class="flex-shrink-0 pe-2"
|
||||
>
|
||||
<avatar
|
||||
:url="user.avatar.url_128"
|
||||
:service="user.avatar.service_name"
|
||||
:service-url="user.avatar.service_url"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
<h2
|
||||
v-if="user.name"
|
||||
class="card-title"
|
||||
>
|
||||
{{ user.name }}
|
||||
</h2>
|
||||
<h2
|
||||
v-else
|
||||
class="card-title"
|
||||
>
|
||||
{{ $gettext('AzuraCast User') }}
|
||||
</h2>
|
||||
<h3 class="card-subtitle">
|
||||
{{ user.email }}
|
||||
</h3>
|
||||
<section
|
||||
class="card mb-4"
|
||||
role="region"
|
||||
:aria-label="$gettext('Account Details')"
|
||||
>
|
||||
<user-info-panel ref="$userInfoPanel">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-dark"
|
||||
@click="doEditProfile"
|
||||
>
|
||||
<icon :icon="IconEdit" />
|
||||
<span>
|
||||
{{ $gettext('Edit Profile') }}
|
||||
</span>
|
||||
</button>
|
||||
</user-info-panel>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="user.roles.length > 0"
|
||||
class="mt-2"
|
||||
>
|
||||
<span
|
||||
v-for="role in user.roles"
|
||||
:key="role.id"
|
||||
class="badge text-bg-secondary me-2"
|
||||
>{{ role.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</loading>
|
||||
|
||||
<template #footer_actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doEditProfile"
|
||||
>
|
||||
<icon :icon="IconEdit" />
|
||||
<span>
|
||||
{{ $gettext('Edit Profile') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</card-page>
|
||||
|
||||
<card-page
|
||||
header-id="hdr_security"
|
||||
:title="$gettext('Security')"
|
||||
>
|
||||
<loading :loading="securityLoading">
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
{{ $gettext('Two-Factor Authentication') }}
|
||||
<enabled-badge :enabled="security.twoFactorEnabled" />
|
||||
</h5>
|
||||
|
||||
<p class="card-text mt-2">
|
||||
{{
|
||||
$gettext('Two-factor authentication improves the security of your account by requiring a second one-time access code in addition to your password when you log in.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</loading>
|
||||
|
||||
<template #footer_actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doChangePassword"
|
||||
>
|
||||
<icon :icon="IconVpnKey" />
|
||||
<span>
|
||||
{{ $gettext('Change Password') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="security.twoFactorEnabled"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="disableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLockOpen" />
|
||||
<span>
|
||||
{{ $gettext('Disable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
@click="enableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLock" />
|
||||
<span>
|
||||
{{ $gettext('Enable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</card-page>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6 col-lg-7">
|
||||
<card-page
|
||||
header-id="hdr_api_keys"
|
||||
:title="$gettext('API Keys')"
|
||||
>
|
||||
<template #info>
|
||||
{{
|
||||
$gettext('Use API keys to authenticate with the AzuraCast API using the same permissions as your user account.')
|
||||
}}
|
||||
|
||||
<a
|
||||
href="/api"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('API Documentation') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="createApiKey"
|
||||
>
|
||||
<icon :icon="IconAdd" />
|
||||
<span>
|
||||
{{ $gettext('Add API Key') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<data-table
|
||||
id="account_api_keys"
|
||||
ref="$dataTable"
|
||||
:show-toolbar="false"
|
||||
:fields="apiKeyFields"
|
||||
:api-url="apiKeysApiUrl"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deleteApiKey(row.item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
</div>
|
||||
<div class="row row-of-cards">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<security-panel />
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<api-keys-panel />
|
||||
</div>
|
||||
|
||||
<account-edit-modal
|
||||
ref="$editModal"
|
||||
:user-url="userUrl"
|
||||
:supported-locales="supportedLocales"
|
||||
@reload="reload"
|
||||
/>
|
||||
|
||||
<account-change-password-modal
|
||||
ref="$changePasswordModal"
|
||||
:change-password-url="changePasswordUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
|
||||
<account-two-factor-modal
|
||||
ref="$twoFactorModal"
|
||||
:two-factor-url="twoFactorUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
|
||||
<account-api-key-modal
|
||||
ref="$apiKeyModal"
|
||||
:create-url="apiKeysApiUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<account-edit-modal
|
||||
ref="$editModal"
|
||||
:supported-locales="supportedLocales"
|
||||
@reload="onProfileEdited"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
|
||||
import AccountChangePasswordModal from "./Account/ChangePasswordModal.vue";
|
||||
import AccountApiKeyModal from "./Account/ApiKeyModal.vue";
|
||||
import AccountTwoFactorModal from "./Account/TwoFactorModal.vue";
|
||||
import AccountEditModal from "./Account/EditModal.vue";
|
||||
import Avatar from "~/components/Common/Avatar.vue";
|
||||
import EnabledBadge from "~/components/Common/Badges/EnabledBadge.vue";
|
||||
import {ref} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import {IconAdd, IconEdit, IconLock, IconLockOpen, IconVpnKey} from "~/components/Common/icons";
|
||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import {IconEdit} from "~/components/Common/icons";
|
||||
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
||||
import SecurityPanel from "~/components/Account/SecurityPanel.vue";
|
||||
import ApiKeysPanel from "~/components/Account/ApiKeysPanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
userUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
changePasswordUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
twoFactorUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
apiKeysApiUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
supportedLocales: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
|
@ -248,94 +56,13 @@ const props = defineProps({
|
|||
}
|
||||
});
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {state: user, isLoading: userLoading, execute: reloadUser} = useRefreshableAsyncState(
|
||||
() => axios.get(props.userUrl).then((r) => r.data),
|
||||
{
|
||||
name: null,
|
||||
email: null,
|
||||
avatar: {
|
||||
url_128: null,
|
||||
service_name: null,
|
||||
service_url: null
|
||||
},
|
||||
roles: [],
|
||||
},
|
||||
);
|
||||
|
||||
const {state: security, isLoading: securityLoading, execute: reloadSecurity} = useRefreshableAsyncState(
|
||||
() => axios.get(props.twoFactorUrl).then((r) => {
|
||||
return {
|
||||
twoFactorEnabled: r.data.two_factor_enabled
|
||||
};
|
||||
}),
|
||||
{
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const apiKeyFields: DataTableField[] = [
|
||||
{
|
||||
key: 'comment',
|
||||
isRowHeader: true,
|
||||
label: $gettext('API Key Description/Comments'),
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: $gettext('Actions'),
|
||||
sortable: false,
|
||||
class: 'shrink'
|
||||
}
|
||||
];
|
||||
|
||||
const $dataTable = ref<DataTableTemplateRef>(null);
|
||||
|
||||
const relist = () => {
|
||||
reloadUser();
|
||||
reloadSecurity();
|
||||
$dataTable.value?.relist();
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const $editModal = ref<InstanceType<typeof AccountEditModal> | null>(null);
|
||||
|
||||
const doEditProfile = () => {
|
||||
$editModal.value?.open();
|
||||
};
|
||||
|
||||
const $changePasswordModal = ref<InstanceType<typeof AccountChangePasswordModal> | null>(null);
|
||||
|
||||
const doChangePassword = () => {
|
||||
$changePasswordModal.value?.open();
|
||||
const onProfileEdited = () => {
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const $twoFactorModal = ref<InstanceType<typeof AccountTwoFactorModal> | null>(null);
|
||||
|
||||
const enableTwoFactor = () => {
|
||||
$twoFactorModal.value?.open();
|
||||
};
|
||||
|
||||
const {doDelete: doDisableTwoFactor} = useConfirmAndDelete(
|
||||
$gettext('Disable two-factor authentication?'),
|
||||
relist
|
||||
);
|
||||
const disableTwoFactor = () => doDisableTwoFactor(props.twoFactorUrl);
|
||||
|
||||
const $apiKeyModal = ref<InstanceType<typeof AccountApiKeyModal> | null>(null);
|
||||
|
||||
const createApiKey = () => {
|
||||
$apiKeyModal.value?.create();
|
||||
};
|
||||
|
||||
const {doDelete: deleteApiKey} = useConfirmAndDelete(
|
||||
$gettext('Delete API Key?'),
|
||||
relist
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<card-page
|
||||
header-id="hdr_api_keys"
|
||||
:title="$gettext('API Keys')"
|
||||
>
|
||||
<template #info>
|
||||
{{
|
||||
$gettext('Use API keys to authenticate with the AzuraCast API using the same permissions as your user account.')
|
||||
}}
|
||||
|
||||
<a
|
||||
href="/api"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('API Documentation') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="createApiKey"
|
||||
>
|
||||
<icon :icon="IconAdd" />
|
||||
<span>
|
||||
{{ $gettext('Add API Key') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<data-table
|
||||
id="account_api_keys"
|
||||
ref="$dataTable"
|
||||
:show-toolbar="false"
|
||||
:fields="apiKeyFields"
|
||||
:api-url="apiKeysApiUrl"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deleteApiKey(row.item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
|
||||
<account-api-key-modal
|
||||
ref="$apiKeyModal"
|
||||
:create-url="apiKeysApiUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {IconAdd} from "~/components/Common/icons.ts";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import AccountApiKeyModal from "~/components/Account/ApiKeyModal.vue";
|
||||
import {ref} from "vue";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const apiKeysApiUrl = getApiUrl('/frontend/account/api-keys');
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const apiKeyFields: DataTableField[] = [
|
||||
{
|
||||
key: 'comment',
|
||||
isRowHeader: true,
|
||||
label: $gettext('API Key Description/Comments'),
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: $gettext('Actions'),
|
||||
sortable: false,
|
||||
class: 'shrink'
|
||||
}
|
||||
];
|
||||
|
||||
const $apiKeyModal = ref<InstanceType<typeof AccountApiKeyModal> | null>(null);
|
||||
|
||||
const createApiKey = () => {
|
||||
$apiKeyModal.value?.create();
|
||||
};
|
||||
|
||||
const $dataTable = ref<DataTableTemplateRef>(null);
|
||||
const {relist} = useHasDatatable($dataTable);
|
||||
|
||||
const {doDelete: deleteApiKey} = useConfirmAndDelete(
|
||||
$gettext('Delete API Key?'),
|
||||
relist
|
||||
);
|
||||
</script>
|
|
@ -46,16 +46,12 @@ import {ref} from "vue";
|
|||
import {useAxios} from "~/vendor/axios";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
|
||||
const props = defineProps({
|
||||
changePasswordUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const emit = defineEmits(['relist']);
|
||||
|
||||
const changePasswordUrl = getApiUrl('/frontend/account/password');
|
||||
|
||||
const passwordsMatch = (value, siblings) => {
|
||||
return siblings.new_password === value;
|
||||
};
|
||||
|
@ -97,7 +93,7 @@ const {axios} = useAxios();
|
|||
const onSubmit = () => {
|
||||
ifValid(() => {
|
||||
axios
|
||||
.put(props.changePasswordUrl, form.value)
|
||||
.put(changePasswordUrl.value, form.value)
|
||||
.finally(() => {
|
||||
$modal.value?.hide();
|
||||
emit('relist');
|
||||
|
|
|
@ -25,12 +25,9 @@ import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
|||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const props = defineProps({
|
||||
userUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
supportedLocales: {
|
||||
type: Object,
|
||||
required: true
|
||||
|
@ -39,6 +36,8 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
const userUrl = getApiUrl('/frontend/account/me');
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
|
@ -77,7 +76,7 @@ const open = () => {
|
|||
|
||||
$modal.value?.show();
|
||||
|
||||
axios.get(props.userUrl).then((resp) => {
|
||||
axios.get(userUrl.value).then((resp) => {
|
||||
form.value = mergeExisting(form.value, resp.data);
|
||||
loading.value = false;
|
||||
}).catch(() => {
|
||||
|
@ -91,7 +90,7 @@ const doSubmit = () => {
|
|||
|
||||
axios({
|
||||
method: 'PUT',
|
||||
url: props.userUrl,
|
||||
url: userUrl.value,
|
||||
data: form.value
|
||||
}).then(() => {
|
||||
notifySuccess();
|
||||
|
|
|
@ -0,0 +1,193 @@
|
|||
<template>
|
||||
<modal
|
||||
id="api_keys_modal"
|
||||
ref="$modal"
|
||||
size="md"
|
||||
centered
|
||||
:title="$gettext('Add New Passkey')"
|
||||
no-enforce-focus
|
||||
@hidden="onHidden"
|
||||
>
|
||||
<template #default>
|
||||
<div
|
||||
v-show="error != null"
|
||||
class="alert alert-danger"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="isSupported"
|
||||
class="form vue-form"
|
||||
@submit.prevent="doSubmit"
|
||||
>
|
||||
<form-group-field
|
||||
id="form_name"
|
||||
:field="v$.name"
|
||||
autofocus
|
||||
class="mb-3"
|
||||
:label="$gettext('Passkey Nickname')"
|
||||
/>
|
||||
|
||||
<form-markup id="form_select_passkey">
|
||||
<template #label>
|
||||
{{ $gettext('Select Passkey') }}
|
||||
</template>
|
||||
|
||||
<p class="card-text">
|
||||
{{ $gettext('Click the button below to open your browser window to select a passkey.') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="form.createResponse"
|
||||
class="card-text"
|
||||
>
|
||||
{{ $gettext('A passkey has been selected. Submit this form to add it to your account.') }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="selectPasskey"
|
||||
>
|
||||
{{ $gettext('Select Passkey') }}
|
||||
</button>
|
||||
</div>
|
||||
</form-markup>
|
||||
|
||||
<invisible-submit-button />
|
||||
</form>
|
||||
|
||||
<div v-else>
|
||||
<p class="card-text">
|
||||
{{
|
||||
$gettext('Your browser does not support passkeys. Consider updating your browser to the latest version.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #modal-footer="slotProps">
|
||||
<slot
|
||||
name="modal-footer"
|
||||
v-bind="slotProps"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="hide"
|
||||
>
|
||||
{{ $gettext('Close') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
:class="(v$.$invalid) ? 'btn-danger' : 'btn-primary'"
|
||||
@click="doSubmit"
|
||||
>
|
||||
{{ $gettext('Add New Passkey') }}
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton.vue";
|
||||
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||
import {required} from '@vuelidate/validators';
|
||||
import {ref} from "vue";
|
||||
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import Modal from "~/components/Common/Modal.vue";
|
||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||
import FormMarkup from "~/components/Form/FormMarkup.vue";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import useWebAuthn from "~/functions/useWebAuthn.ts";
|
||||
|
||||
const emit = defineEmits(['relist']);
|
||||
|
||||
const registerWebAuthnUrl = getApiUrl('/frontend/account/webauthn/register');
|
||||
|
||||
const error = ref(null);
|
||||
|
||||
const {form, resetForm, v$, validate} = useVuelidateOnForm(
|
||||
{
|
||||
name: {required},
|
||||
createResponse: {required}
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
createResponse: null
|
||||
}
|
||||
);
|
||||
|
||||
const clearContents = () => {
|
||||
resetForm();
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const $modal = ref<ModalTemplateRef>(null);
|
||||
const {show, hide} = useHasModal($modal);
|
||||
|
||||
const create = () => {
|
||||
clearContents();
|
||||
show();
|
||||
};
|
||||
|
||||
const onHidden = () => {
|
||||
clearContents();
|
||||
emit('relist');
|
||||
};
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {isSupported, processServerArgs, processRegisterResponse} = useWebAuthn();
|
||||
|
||||
const selectPasskey = async () => {
|
||||
// GET registration options from the endpoint that calls
|
||||
const registerArgs = await axios.get(registerWebAuthnUrl.value).then(r => processServerArgs(r.data));
|
||||
|
||||
let attResp;
|
||||
try {
|
||||
// Pass the options to the authenticator and wait for a response
|
||||
attResp = await navigator.credentials.create(registerArgs);
|
||||
form.value.createResponse = processRegisterResponse(attResp);
|
||||
} catch (error) {
|
||||
// Some basic error handling
|
||||
if (error.name === 'InvalidStateError') {
|
||||
error.value = 'Error: Authenticator was probably already registered by user';
|
||||
} else {
|
||||
error.value = error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const doSubmit = async () => {
|
||||
const isValid = await validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
|
||||
axios({
|
||||
method: 'PUT',
|
||||
url: registerWebAuthnUrl.value,
|
||||
data: form.value
|
||||
}).then(() => {
|
||||
hide();
|
||||
}).catch((error) => {
|
||||
error.value = error.response.data.message;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
create
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<card-page header-id="hdr_security">
|
||||
<template #header="{id}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-fill">
|
||||
<h3
|
||||
:id="id"
|
||||
class="card-title"
|
||||
>
|
||||
{{ $gettext('Security') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-dark"
|
||||
@click="doChangePassword"
|
||||
>
|
||||
<icon :icon="IconVpnKey" />
|
||||
<span>
|
||||
{{ $gettext('Change Password') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<loading :loading="securityLoading">
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
{{ $gettext('Two-Factor Authentication') }}
|
||||
<enabled-badge :enabled="security.twoFactorEnabled" />
|
||||
</h5>
|
||||
|
||||
<p class="card-text mt-2">
|
||||
{{
|
||||
$gettext('Two-factor authentication improves the security of your account by requiring a second one-time access code in addition to your password when you log in.')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="security.twoFactorEnabled"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="disableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLockOpen" />
|
||||
<span>
|
||||
{{ $gettext('Disable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
@click="enableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLock" />
|
||||
<span>
|
||||
{{ $gettext('Enable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</loading>
|
||||
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
{{ $gettext('Passkey Authentication') }}
|
||||
</h5>
|
||||
|
||||
<p class="card-text mt-2">
|
||||
{{
|
||||
$gettext('Using a passkey (like Windows Hello, YubiKey, or your smartphone) allows you to securely log in without needing to enter your password or two-factor code.')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doAddPasskey"
|
||||
>
|
||||
<icon :icon="IconAdd" />
|
||||
<span>
|
||||
{{ $gettext('Add New Passkey') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<data-table
|
||||
id="account_passkeys"
|
||||
ref="$dataTable"
|
||||
:show-toolbar="false"
|
||||
:fields="passkeyFields"
|
||||
:api-url="passkeysApiUrl"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deletePasskey(row.item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
|
||||
<account-change-password-modal ref="$changePasswordModal" />
|
||||
|
||||
<account-two-factor-modal
|
||||
ref="$twoFactorModal"
|
||||
:two-factor-url="twoFactorUrl"
|
||||
@relist="reloadSecurity"
|
||||
/>
|
||||
|
||||
<passkey-modal
|
||||
ref="$passkeyModal"
|
||||
@relist="reloadPasskeys"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {IconAdd, IconLock, IconLockOpen, IconVpnKey} from "~/components/Common/icons.ts";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import EnabledBadge from "~/components/Common/Badges/EnabledBadge.vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import AccountTwoFactorModal from "~/components/Account/TwoFactorModal.vue";
|
||||
import AccountChangePasswordModal from "~/components/Account/ChangePasswordModal.vue";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {ref} from "vue";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import PasskeyModal from "~/components/Account/PasskeyModal.vue";
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const twoFactorUrl = getApiUrl('/frontend/account/two-factor');
|
||||
|
||||
const {state: security, isLoading: securityLoading, execute: reloadSecurity} = useRefreshableAsyncState(
|
||||
() => axios.get(twoFactorUrl.value).then((r) => {
|
||||
return {
|
||||
twoFactorEnabled: r.data.two_factor_enabled
|
||||
};
|
||||
}),
|
||||
{
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const $changePasswordModal = ref<InstanceType<typeof AccountChangePasswordModal> | null>(null);
|
||||
|
||||
const doChangePassword = () => {
|
||||
$changePasswordModal.value?.open();
|
||||
};
|
||||
|
||||
const $twoFactorModal = ref<InstanceType<typeof AccountTwoFactorModal> | null>(null);
|
||||
|
||||
const enableTwoFactor = () => {
|
||||
$twoFactorModal.value?.open();
|
||||
};
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const {doDelete: doDisableTwoFactor} = useConfirmAndDelete(
|
||||
$gettext('Disable two-factor authentication?'),
|
||||
reloadSecurity
|
||||
);
|
||||
const disableTwoFactor = () => doDisableTwoFactor(twoFactorUrl.value);
|
||||
|
||||
const passkeysApiUrl = getApiUrl('/frontend/account/passkeys');
|
||||
|
||||
const passkeyFields: DataTableField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
isRowHeader: true,
|
||||
label: $gettext('Passkey Nickname'),
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: $gettext('Actions'),
|
||||
sortable: false,
|
||||
class: 'shrink'
|
||||
}
|
||||
];
|
||||
|
||||
const $dataTable = ref<DataTableTemplateRef>(null);
|
||||
const {relist: reloadPasskeys} = useHasDatatable($dataTable);
|
||||
|
||||
const {doDelete: deletePasskey} = useConfirmAndDelete(
|
||||
$gettext('Delete Passkey?'),
|
||||
reloadPasskeys
|
||||
);
|
||||
|
||||
const $passkeyModal = ref<InstanceType<typeof PasskeyModal> | null>(null);
|
||||
|
||||
const doAddPasskey = () => {
|
||||
$passkeyModal.value?.create();
|
||||
};
|
||||
|
||||
</script>
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="card-header text-bg-primary d-flex flex-wrap align-items-center">
|
||||
<avatar
|
||||
v-if="user.avatar.url_128"
|
||||
class="flex-shrink-0 me-3"
|
||||
:url="user.avatar.url_128"
|
||||
:service="user.avatar.service_name"
|
||||
:service-url="user.avatar.service_url"
|
||||
/>
|
||||
|
||||
<div class="flex-fill">
|
||||
<h2
|
||||
v-if="user.name"
|
||||
class="card-title mt-0"
|
||||
>
|
||||
{{ user.name }}
|
||||
</h2>
|
||||
<h2
|
||||
v-else
|
||||
class="card-title"
|
||||
>
|
||||
{{ $gettext('AzuraCast User') }}
|
||||
</h2>
|
||||
<h3 class="card-subtitle">
|
||||
{{ user.email }}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
v-if="user.roles.length > 0"
|
||||
class="mt-2"
|
||||
>
|
||||
<span
|
||||
v-for="role in user.roles"
|
||||
:key="role.id"
|
||||
class="badge text-bg-secondary me-2"
|
||||
>{{ role.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.default"
|
||||
class="flex-md-shrink-0 mt-3 mt-md-0 buttons"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Avatar from "~/components/Common/Avatar.vue";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const slots = defineSlots();
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const userUrl = getApiUrl('/frontend/account/me');
|
||||
|
||||
const {state: user, execute: reload} = useRefreshableAsyncState(
|
||||
() => axios.get(userUrl.value).then((r) => r.data),
|
||||
{
|
||||
name: null,
|
||||
email: null,
|
||||
avatar: {
|
||||
url_128: null,
|
||||
service_name: null,
|
||||
service_url: null
|
||||
},
|
||||
roles: [],
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
reload
|
||||
});
|
||||
</script>
|
|
@ -8,44 +8,25 @@
|
|||
role="region"
|
||||
:aria-label="$gettext('Account Details')"
|
||||
>
|
||||
<div class="card-header text-bg-primary d-flex flex-wrap align-items-center">
|
||||
<avatar
|
||||
v-if="user.avatar.url"
|
||||
class="flex-shrink-0 me-3"
|
||||
:url="user.avatar.url"
|
||||
:service="user.avatar.service"
|
||||
:service-url="user.avatar.serviceUrl"
|
||||
/>
|
||||
|
||||
<div class="flex-fill">
|
||||
<h2 class="card-title mt-0">
|
||||
{{ user.name }}
|
||||
</h2>
|
||||
<h3 class="card-subtitle">
|
||||
{{ user.email }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex-md-shrink-0 mt-3 mt-md-0 buttons">
|
||||
<a
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="profileUrl"
|
||||
>
|
||||
<icon :icon="IconAccountCircle" />
|
||||
<span>{{ $gettext('My Account') }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="showAdmin"
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="adminUrl"
|
||||
>
|
||||
<icon :icon="IconSettings" />
|
||||
<span>{{ $gettext('Administration') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<user-info-panel>
|
||||
<a
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="profileUrl"
|
||||
>
|
||||
<icon :icon="IconAccountCircle" />
|
||||
<span>{{ $gettext('My Account') }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="showAdmin"
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="adminUrl"
|
||||
>
|
||||
<icon :icon="IconSettings" />
|
||||
<span>{{ $gettext('Administration') }}</span>
|
||||
</a>
|
||||
</user-info-panel>
|
||||
|
||||
<template v-if="!notificationsLoading && notifications.length > 0">
|
||||
<div
|
||||
|
@ -292,7 +273,6 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import Avatar from '~/components/Common/Avatar.vue';
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
|
@ -308,12 +288,10 @@ import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
|
|||
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox";
|
||||
import useOptionalStorage from "~/functions/useOptionalStorage";
|
||||
import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons";
|
||||
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const props = defineProps({
|
||||
userUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
profileUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
|
@ -326,32 +304,24 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
notificationsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showCharts: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
chartsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
manageStationsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
stationsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showAlbumArt: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const notificationsUrl = getApiUrl('/frontend/dashboard/notifications');
|
||||
const chartsUrl = getApiUrl('/frontend/dashboard/charts');
|
||||
const stationsUrl = getApiUrl('/frontend/dashboard/stations');
|
||||
|
||||
const chartsVisible = useOptionalStorage<boolean>('dashboard_show_chart', true);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
@ -364,37 +334,13 @@ const langShowHideCharts = computed(() => {
|
|||
|
||||
const {axios, axiosSilent} = useAxios();
|
||||
|
||||
const {state: user} = useAsyncState(
|
||||
() => axios.get(props.userUrl)
|
||||
.then((resp) => {
|
||||
return {
|
||||
name: resp.data.name,
|
||||
email: resp.data.email,
|
||||
avatar: {
|
||||
url: resp.data.avatar.url_64,
|
||||
service: resp.data.avatar.service_name,
|
||||
serviceUrl: resp.data.avatar.service_url
|
||||
}
|
||||
};
|
||||
}),
|
||||
{
|
||||
name: $gettext('AzuraCast User'),
|
||||
email: null,
|
||||
avatar: {
|
||||
url: null,
|
||||
service: null,
|
||||
serviceUrl: null
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
|
||||
() => axios.get(props.notificationsUrl).then((r) => r.data),
|
||||
() => axios.get(notificationsUrl.value).then((r) => r.data),
|
||||
[]
|
||||
);
|
||||
|
||||
const {state: stations, isLoading: stationsLoading, execute: reloadStations} = useRefreshableAsyncState(
|
||||
() => axiosSilent.get(props.stationsUrl).then((r) => r.data),
|
||||
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
<template>
|
||||
<div class="public-page">
|
||||
<div class="card p-2">
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm">
|
||||
<h2
|
||||
v-if="hideProductName"
|
||||
class="card-title text-center"
|
||||
>
|
||||
{{ $gettext('Welcome!') }}
|
||||
</h2>
|
||||
<h2
|
||||
v-else
|
||||
class="card-title text-center"
|
||||
>
|
||||
{{ $gettext('Welcome to AzuraCast!') }}
|
||||
</h2>
|
||||
<h3
|
||||
v-if="instanceName"
|
||||
class="card-subtitle text-center text-muted"
|
||||
>
|
||||
{{ instanceName }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="login-form"
|
||||
action=""
|
||||
method="post"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label
|
||||
for="username"
|
||||
class="mb-2 d-flex align-items-center gap-2"
|
||||
>
|
||||
<icon :icon="IconMail" />
|
||||
<strong>
|
||||
{{ $gettext('E-mail Address') }}
|
||||
</strong>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="email"
|
||||
name="username"
|
||||
class="form-control"
|
||||
:placeholder="$gettext('name@example.com')"
|
||||
:aria-label="$gettext('E-mail Address')"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<label
|
||||
for="password"
|
||||
class="mb-2 d-flex align-items-center gap-2"
|
||||
>
|
||||
<icon :icon="IconVpnKey" />
|
||||
<strong>{{ $gettext('Password') }}</strong>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
:placeholder="$gettext('Enter your password')"
|
||||
:aria-label="$gettext('Password')"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input
|
||||
id="frm_remember_me"
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
value="1"
|
||||
class="toggle-switch custom-control-input"
|
||||
>
|
||||
<label
|
||||
for="frm_remember_me"
|
||||
class="custom-control-label"
|
||||
>
|
||||
{{ $gettext('Remember me') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-buttons mt-3 mb-3">
|
||||
<button
|
||||
type="submit"
|
||||
role="button"
|
||||
:title="$gettext('Sign In')"
|
||||
class="btn btn-login btn-primary"
|
||||
>
|
||||
{{ $gettext('Sign In') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-if="passkeySupported"
|
||||
id="webauthn-form"
|
||||
ref="$webAuthnForm"
|
||||
:action="webAuthnUrl"
|
||||
method="post"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="validateData"
|
||||
:value="validateData"
|
||||
>
|
||||
|
||||
<div class="block-buttons mb-3">
|
||||
<button
|
||||
type="button"
|
||||
role="button"
|
||||
:title="$gettext('Sign In with Passkey')"
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="logInWithPasskey"
|
||||
>
|
||||
{{ $gettext('Sign In with Passkey') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-center m-0">
|
||||
{{ $gettext('Please log in to continue.') }}
|
||||
|
||||
<a :href="forgotPasswordUrl">
|
||||
{{ $gettext('Forgot your password?') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import {IconMail, IconVpnKey} from "~/components/Common/icons.ts";
|
||||
import useWebAuthn from "~/functions/useWebAuthn.ts";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import {nextTick, ref} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
hideProductName: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
instanceName: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
forgotPasswordUrl: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
webAuthnUrl: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const {isSupported: passkeySupported, processServerArgs, processValidateResponse} = useWebAuthn();
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const $webAuthnForm = ref<HTMLFormElement | null>(null);
|
||||
const validateData = ref<string | null>(null);
|
||||
|
||||
const logInWithPasskey = async () => {
|
||||
const validateArgs = await axios.get(props.webAuthnUrl).then(r => processServerArgs(r.data));
|
||||
|
||||
const attResp = await navigator.credentials.get(validateArgs);
|
||||
validateData.value = JSON.stringify(processValidateResponse(attResp));
|
||||
|
||||
await nextTick();
|
||||
|
||||
$webAuthnForm.value?.submit();
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,71 @@
|
|||
import {cloneDeep} from "lodash";
|
||||
|
||||
export default function useWebAuthn() {
|
||||
const recursiveBase64StrToArrayBuffer = (obj) => {
|
||||
const prefix = '=?BINARY?B?';
|
||||
const suffix = '?=';
|
||||
if (typeof obj === 'object') {
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === 'string') {
|
||||
let str = obj[key];
|
||||
if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) {
|
||||
str = str.substring(prefix.length, str.length - suffix.length);
|
||||
|
||||
const binary_string = window.atob(str);
|
||||
const len = binary_string.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary_string.charCodeAt(i);
|
||||
}
|
||||
obj[key] = bytes.buffer;
|
||||
}
|
||||
} else {
|
||||
recursiveBase64StrToArrayBuffer(obj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBufferToBase64 = (buffer) => {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
const isSupported: boolean = !!window.fetch && !!navigator.credentials && !!navigator.credentials.create;
|
||||
|
||||
const processServerArgs = (serverArgs) => {
|
||||
const newArgs = cloneDeep(serverArgs);
|
||||
recursiveBase64StrToArrayBuffer(newArgs);
|
||||
return newArgs;
|
||||
};
|
||||
|
||||
const processRegisterResponse = (cred) => {
|
||||
return {
|
||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
|
||||
};
|
||||
}
|
||||
|
||||
const processValidateResponse = (cred) => {
|
||||
return {
|
||||
id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
|
||||
signature: cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null,
|
||||
userHandle: cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
processServerArgs,
|
||||
processRegisterResponse,
|
||||
processValidateResponse,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import initApp from "~/layout";
|
||||
import useMinimalLayout from "~/layouts/MinimalLayout";
|
||||
import Login from "~/components/Login.vue";
|
||||
|
||||
initApp(useMinimalLayout(Login));
|
|
@ -172,9 +172,14 @@ final class Auth
|
|||
*
|
||||
* @param User $user
|
||||
*/
|
||||
public function setUser(User $user): void
|
||||
public function setUser(User $user, ?bool $isLoginComplete = null): void
|
||||
{
|
||||
$this->session->set(self::SESSION_IS_LOGIN_COMPLETE_KEY, null === $user->getTwoFactorSecret());
|
||||
$this->session->set(
|
||||
self::SESSION_IS_LOGIN_COMPLETE_KEY,
|
||||
(null !== $isLoginComplete)
|
||||
? $isLoginComplete
|
||||
: null === $user->getTwoFactorSecret()
|
||||
);
|
||||
$this->session->set(self::SESSION_USER_ID_KEY, $user->getId());
|
||||
$this->session->regenerate();
|
||||
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Frontend\Account;
|
||||
|
||||
use App\Controller\Api\AbstractApiCrudController;
|
||||
use App\Entity\Interfaces\EntityGroupsInterface;
|
||||
use App\Entity\UserPasskey;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
|
||||
/**
|
||||
* @template TEntity as UserPasskey
|
||||
* @extends AbstractApiCrudController<TEntity>
|
||||
*/
|
||||
final class PasskeysController extends AbstractApiCrudController
|
||||
{
|
||||
protected string $entityClass = UserPasskey::class;
|
||||
protected string $resourceRouteName = 'api:frontend:passkey';
|
||||
|
||||
public function listAction(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$query = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT e FROM App\Entity\UserPasskey e WHERE e.user = :user
|
||||
DQL
|
||||
)->setParameter('user', $request->getUser());
|
||||
|
||||
return $this->listPaginatedFromQuery($request, $response, $query);
|
||||
}
|
||||
|
||||
public function createAction(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
throw new RuntimeException('Not implemented. See /frontend/account/webauthn/register.');
|
||||
}
|
||||
|
||||
public function editAction(ServerRequest $request, Response $response, array $params): ResponseInterface
|
||||
{
|
||||
throw new RuntimeException('Not implemented.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserPasskey|null
|
||||
*/
|
||||
protected function getRecord(ServerRequest $request, array $params): ?object
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $params['id'];
|
||||
|
||||
/** @var UserPasskey|null $record */
|
||||
$record = $this->em->getRepository(UserPasskey::class)->findOneBy([
|
||||
'id' => $id,
|
||||
'user' => $request->getUser(),
|
||||
]);
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TEntity $record
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected function toArray(object $record, array $context = []): array
|
||||
{
|
||||
$context[AbstractNormalizer::GROUPS] = [
|
||||
EntityGroupsInterface::GROUP_ID,
|
||||
EntityGroupsInterface::GROUP_GENERAL,
|
||||
];
|
||||
|
||||
return parent::toArray($record, $context);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Frontend\Account\WebAuthn;
|
||||
|
||||
use App\Controller\Traits\UsesWebAuthnTrait;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class GetRegistrationAction
|
||||
{
|
||||
use UsesWebAuthnTrait;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$user = $request->getUser();
|
||||
$webAuthn = $this->getWebAuthn($request);
|
||||
|
||||
$createArgs = $webAuthn->getCreateArgs(
|
||||
(string)$user->getId(),
|
||||
$user->getEmail(),
|
||||
$user->getDisplayName(),
|
||||
self::WEBAUTHN_TIMEOUT,
|
||||
requireResidentKey: 'required',
|
||||
requireUserVerification: 'preferred',
|
||||
);
|
||||
|
||||
$this->setChallenge($request, $webAuthn->getChallenge());
|
||||
|
||||
return $response->withJson($createArgs);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Frontend\Account\WebAuthn;
|
||||
|
||||
use App\Container\EntityManagerAwareTrait;
|
||||
use App\Controller\Traits\UsesWebAuthnTrait;
|
||||
use App\Entity\Api\Status;
|
||||
use App\Entity\UserPasskey;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Security\WebAuthnPasskey;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class PutRegistrationAction
|
||||
{
|
||||
use UsesWebAuthnTrait;
|
||||
use EntityManagerAwareTrait;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$user = $request->getUser();
|
||||
|
||||
$webAuthn = $this->getWebAuthn($request);
|
||||
|
||||
$parsedBody = $request->getParsedBody();
|
||||
$challenge = $this->getChallenge($request);
|
||||
|
||||
// Turn the submitted data into a raw passkey.
|
||||
$passkeyRaw = $webAuthn->processCreate(
|
||||
base64_decode($parsedBody['createResponse']['clientDataJSON'] ?? ''),
|
||||
base64_decode($parsedBody['createResponse']['attestationObject'] ?? ''),
|
||||
$challenge
|
||||
);
|
||||
|
||||
$passkey = WebAuthnPasskey::fromWebAuthnObject($passkeyRaw);
|
||||
|
||||
$record = new UserPasskey(
|
||||
$user,
|
||||
$parsedBody['name'] ?? 'New Passkey',
|
||||
$passkey
|
||||
);
|
||||
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $response->withJson(Status::success());
|
||||
}
|
||||
}
|
|
@ -122,6 +122,21 @@ final class LoginAction implements SingleActionInterface
|
|||
return $response->withRedirect((string)$request->getUri());
|
||||
}
|
||||
|
||||
return $request->getView()->renderToResponse($response, 'frontend/account/login');
|
||||
$customization = $request->getCustomization();
|
||||
$router = $request->getRouter();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Login',
|
||||
id: 'account-login',
|
||||
layout: 'minimal',
|
||||
title: __('Log In'),
|
||||
props: [
|
||||
'hideProductName' => $customization->hideProductName(),
|
||||
'instanceName' => $customization->getInstanceName(),
|
||||
'forgotPasswordUrl' => $router->named('account:forgot'),
|
||||
'webAuthnUrl' => $router->named('account:webauthn'),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Frontend\Account\WebAuthn;
|
||||
|
||||
use App\Controller\Traits\UsesWebAuthnTrait;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class GetValidationAction
|
||||
{
|
||||
use UsesWebAuthnTrait;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$webAuthn = $this->getWebAuthn($request);
|
||||
|
||||
$getArgs = $webAuthn->getGetArgs(
|
||||
[],
|
||||
self::WEBAUTHN_TIMEOUT
|
||||
);
|
||||
|
||||
$this->setChallenge($request, $webAuthn->getChallenge());
|
||||
|
||||
return $response->withJson($getArgs);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Frontend\Account\WebAuthn;
|
||||
|
||||
use App\Controller\Traits\UsesWebAuthnTrait;
|
||||
use App\Entity\Repository\UserPasskeyRepository;
|
||||
use App\Entity\UserPasskey;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use InvalidArgumentException;
|
||||
use Mezzio\Session\SessionCookiePersistenceInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
final class PostValidationAction
|
||||
{
|
||||
use UsesWebAuthnTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserPasskeyRepository $passkeyRepo
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$webAuthn = $this->getWebAuthn($request);
|
||||
|
||||
$parsedBody = $request->getParsedBody();
|
||||
$validateData = json_decode($parsedBody['validateData'] ?? '', true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$challenge = $this->getChallenge($request);
|
||||
|
||||
try {
|
||||
$record = $this->passkeyRepo->findById(base64_decode($validateData['id']));
|
||||
if (!($record instanceof UserPasskey)) {
|
||||
throw new InvalidArgumentException('This passkey does not correspond to a valid user.');
|
||||
}
|
||||
|
||||
// Validate the passkey. Exception thrown if invalid.
|
||||
$webAuthn->processGet(
|
||||
base64_decode($validateData['clientDataJSON'] ?? ''),
|
||||
base64_decode($validateData['authenticatorData'] ?? ''),
|
||||
base64_decode($validateData['signature'] ?? ''),
|
||||
$record->getPasskey()->getPublicKeyPem(),
|
||||
$challenge
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$flash = $request->getFlash();
|
||||
$flash->error(
|
||||
'<b>' . __('Login unsuccessful') . '</b><br>' . $e->getMessage(),
|
||||
);
|
||||
|
||||
return $response->withRedirect($request->getRouter()->named('dashboard'));
|
||||
}
|
||||
|
||||
$user = $record->getUser();
|
||||
|
||||
$auth = $request->getAuth();
|
||||
$auth->setUser($user, true);
|
||||
|
||||
$session = $request->getSession();
|
||||
if ($session instanceof SessionCookiePersistenceInterface) {
|
||||
$session->persistSessionFor(86400 * 14);
|
||||
}
|
||||
|
||||
$acl = $request->getAcl();
|
||||
$acl->reload();
|
||||
|
||||
$flash = $request->getFlash();
|
||||
$flash->success(
|
||||
'<b>' . __('Logged in successfully.') . '</b><br>' . $user->getEmail(),
|
||||
);
|
||||
|
||||
$referrer = $session->get('login_referrer');
|
||||
return $response->withRedirect(
|
||||
(!empty($referrer)) ? $referrer : $request->getRouter()->named('dashboard')
|
||||
);
|
||||
}
|
||||
}
|
|
@ -34,15 +34,11 @@ final class DashboardAction implements SingleActionInterface
|
|||
id: 'dashboard',
|
||||
title: __('Dashboard'),
|
||||
props: [
|
||||
'userUrl' => $router->named('api:frontend:account:me'),
|
||||
'profileUrl' => $router->named('profile:index'),
|
||||
'adminUrl' => $router->named('admin:index:index'),
|
||||
'showAdmin' => $acl->isAllowed(GlobalPermissions::View),
|
||||
'notificationsUrl' => $router->named('api:frontend:dashboard:notifications'),
|
||||
'showCharts' => $showCharts,
|
||||
'chartsUrl' => $router->named('api:frontend:dashboard:charts'),
|
||||
'manageStationsUrl' => $router->named('admin:stations:index'),
|
||||
'stationsUrl' => $router->named('api:frontend:dashboard:stations'),
|
||||
'showAlbumArt' => !$settings->getHideAlbumArt(),
|
||||
]
|
||||
);
|
||||
|
|
|
@ -17,8 +17,6 @@ final class IndexAction implements SingleActionInterface
|
|||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$router = $request->getRouter();
|
||||
|
||||
$supportedLocales = [];
|
||||
foreach (SupportedLocales::cases() as $supportedLocale) {
|
||||
$supportedLocales[$supportedLocale->value] = $supportedLocale->getLocalName();
|
||||
|
@ -30,10 +28,6 @@ final class IndexAction implements SingleActionInterface
|
|||
id: 'account',
|
||||
title: __('My Account'),
|
||||
props: [
|
||||
'userUrl' => $router->named('api:frontend:account:me'),
|
||||
'changePasswordUrl' => $router->named('api:frontend:account:password'),
|
||||
'twoFactorUrl' => $router->named('api:frontend:account:two-factor'),
|
||||
'apiKeysApiUrl' => $router->named('api:frontend:api-keys'),
|
||||
'supportedLocales' => $supportedLocales,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use App\Container\SettingsAwareTrait;
|
||||
use App\Http\ServerRequest;
|
||||
use InvalidArgumentException;
|
||||
use lbuchs\WebAuthn\Binary\ByteBuffer;
|
||||
use lbuchs\WebAuthn\WebAuthn;
|
||||
|
||||
trait UsesWebAuthnTrait
|
||||
{
|
||||
use SettingsAwareTrait;
|
||||
|
||||
protected const SESSION_CHALLENGE_KEY = 'webauthn_challenge';
|
||||
protected const WEBAUTHN_TIMEOUT = 300;
|
||||
|
||||
protected ?WebAuthn $webAuthn = null;
|
||||
|
||||
protected function getWebAuthn(ServerRequest $request): WebAuthn
|
||||
{
|
||||
if (null === $this->webAuthn) {
|
||||
$settings = $this->settingsRepo->readSettings();
|
||||
$router = $request->getRouter();
|
||||
|
||||
$this->webAuthn = new WebAuthn(
|
||||
$settings->getInstanceName() ?? 'AzuraCast',
|
||||
$router->getBaseUrl()->getHost()
|
||||
);
|
||||
}
|
||||
|
||||
return $this->webAuthn;
|
||||
}
|
||||
|
||||
protected function setChallenge(
|
||||
ServerRequest $request,
|
||||
ByteBuffer $challenge
|
||||
): void {
|
||||
$session = $request->getSession();
|
||||
|
||||
$session->set(
|
||||
self::SESSION_CHALLENGE_KEY,
|
||||
$challenge->getHex()
|
||||
);
|
||||
}
|
||||
|
||||
protected function getChallenge(
|
||||
ServerRequest $request
|
||||
): ByteBuffer {
|
||||
$session = $request->getSession();
|
||||
$challengeRaw = $session->get(self::SESSION_CHALLENGE_KEY);
|
||||
|
||||
if (empty($challengeRaw)) {
|
||||
throw new InvalidArgumentException('Invalid challenge provided.');
|
||||
}
|
||||
|
||||
return ByteBuffer::fromHex($challengeRaw);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20231125215905 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add table for user passkeys.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE user_passkeys (id VARCHAR(64) NOT NULL, user_id INT NOT NULL, created_at INT NOT NULL, name VARCHAR(255) NOT NULL, full_id LONGTEXT NOT NULL, public_key_pem LONGTEXT NOT NULL, INDEX IDX_A2309328A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE user_passkeys ADD CONSTRAINT FK_A2309328A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user_passkeys DROP FOREIGN KEY FK_A2309328A76ED395');
|
||||
$this->addSql('DROP TABLE user_passkeys');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity\UserPasskey;
|
||||
use App\Security\WebAuthnPasskey;
|
||||
|
||||
/**
|
||||
* @extends Repository<UserPasskey>
|
||||
*/
|
||||
final class UserPasskeyRepository extends Repository
|
||||
{
|
||||
protected string $entityClass = UserPasskey::class;
|
||||
|
||||
public function findById(string $id): ?UserPasskey
|
||||
{
|
||||
$record = $this->repository->find(WebAuthnPasskey::hashIdentifier($id));
|
||||
if (!($record instanceof UserPasskey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record->verifyFullId($id);
|
||||
return $record;
|
||||
}
|
||||
}
|
|
@ -125,6 +125,14 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
]
|
||||
protected Collection $api_keys;
|
||||
|
||||
/** @var Collection<int, UserPasskey> */
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'user', targetEntity: ApiKey::class),
|
||||
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
|
||||
DeepNormalize(true)
|
||||
]
|
||||
protected Collection $passkeys;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->created_at = time();
|
||||
|
@ -274,6 +282,14 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
return $this->api_keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, UserPasskey>
|
||||
*/
|
||||
public function getPasskeys(): Collection
|
||||
{
|
||||
return $this->passkeys;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getName() . ' (' . $this->getEmail() . ')';
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Interfaces\EntityGroupsInterface;
|
||||
use App\Entity\Interfaces\IdentifiableEntityInterface;
|
||||
use App\Entity\Traits\TruncateStrings;
|
||||
use App\Security\WebAuthnPasskey;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
||||
#[
|
||||
ORM\Entity(readOnly: true),
|
||||
ORM\Table(name: 'user_passkeys')
|
||||
]
|
||||
class UserPasskey implements IdentifiableEntityInterface
|
||||
{
|
||||
use TruncateStrings;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
#[ORM\Id]
|
||||
#[Groups([
|
||||
EntityGroupsInterface::GROUP_ID,
|
||||
EntityGroupsInterface::GROUP_ALL,
|
||||
])]
|
||||
protected string $id;
|
||||
|
||||
#[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'passkeys')]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
protected User $user;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups([
|
||||
EntityGroupsInterface::GROUP_GENERAL,
|
||||
EntityGroupsInterface::GROUP_ALL,
|
||||
])]
|
||||
protected int $created_at;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups([
|
||||
EntityGroupsInterface::GROUP_GENERAL,
|
||||
EntityGroupsInterface::GROUP_ALL,
|
||||
])]
|
||||
protected string $name;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
protected string $full_id;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
protected string $public_key_pem;
|
||||
|
||||
public function __construct(User $user, string $name, WebAuthnPasskey $passkey)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->name = $this->truncateString($name);
|
||||
$this->id = $passkey->getHashedId();
|
||||
$this->full_id = base64_encode($passkey->getId());
|
||||
$this->public_key_pem = $passkey->getPublicKeyPem();
|
||||
$this->created_at = time();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getIdRequired(): int|string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): int
|
||||
{
|
||||
return $this->created_at;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getPasskey(): WebAuthnPasskey
|
||||
{
|
||||
return new WebAuthnPasskey(
|
||||
base64_decode($this->full_id),
|
||||
$this->public_key_pem
|
||||
);
|
||||
}
|
||||
|
||||
public function verifyFullId(string $fullId): void
|
||||
{
|
||||
if (!hash_equals($this->getPasskey()->getId(), $fullId)) {
|
||||
throw new InvalidArgumentException('Full ID does not match passkey.');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use stdClass;
|
||||
|
||||
final class WebAuthnPasskey
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly string $id,
|
||||
protected readonly string $publicKeyPem
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHashedId(): string
|
||||
{
|
||||
return self::hashIdentifier($this->id);
|
||||
}
|
||||
|
||||
public function getPublicKeyPem(): string
|
||||
{
|
||||
return $this->publicKeyPem;
|
||||
}
|
||||
|
||||
public static function hashIdentifier(string $id): string
|
||||
{
|
||||
return hash('sha256', $id);
|
||||
}
|
||||
|
||||
public static function fromWebAuthnObject(stdClass $data): self
|
||||
{
|
||||
return new self(
|
||||
$data->credentialId,
|
||||
$data->credentialPublicKey
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\Customization $customization
|
||||
* @var App\Environment $environment
|
||||
* @var App\Http\RouterInterface $router
|
||||
*/
|
||||
|
||||
$this->layout(
|
||||
'minimal',
|
||||
[
|
||||
'title' => __('Log In'),
|
||||
'page_class' => 'login-content',
|
||||
]
|
||||
);
|
||||
?>
|
||||
|
||||
<div class="public-page">
|
||||
<div class="card p-2">
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm">
|
||||
<h2 class="card-title text-center">
|
||||
<?php
|
||||
if ($customization->hideProductName()): ?>
|
||||
<?= __('Welcome!') ?>
|
||||
<?php
|
||||
else: ?>
|
||||
<?= sprintf(__('Welcome to %s!'), $environment->getAppName()) ?>
|
||||
<?php
|
||||
endif; ?>
|
||||
</h2>
|
||||
<?php
|
||||
if (!empty($customization->getInstanceName())): ?>
|
||||
<h3 class="card-subtitle text-center text-muted">
|
||||
<?= $this->e($customization->getInstanceName()) ?>
|
||||
</h3>
|
||||
<?php
|
||||
endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="login-form" action="" method="post">
|
||||
<div class="form-group">
|
||||
<label for="username" class="mb-2 d-flex align-items-center gap-2">
|
||||
<?= $this->fetch('icons/mail') ?>
|
||||
<strong><?= __('E-mail Address') ?></strong>
|
||||
</label>
|
||||
<input type="email" id="username" name="username" class="form-control" placeholder="<?= __(
|
||||
'name@example.com'
|
||||
) ?>" aria-label="<?= __('E-mail Address') ?>" required autofocus>
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<label for="password" class="mb-2 d-flex align-items-center gap-2">
|
||||
<?= $this->fetch('icons/password') ?>
|
||||
<strong><?= __('Password') ?></strong>
|
||||
</label>
|
||||
<input type="password" id="password" name="password" class="form-control" placeholder="<?= __(
|
||||
'Enter your password'
|
||||
) ?>" aria-label="<?= __('Password') ?>" required>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" name="remember" id="frm_remember_me" value="1"
|
||||
class="toggle-switch custom-control-input">
|
||||
<label for="frm_remember_me" class="custom-control-label">
|
||||
<?= __('Remember me') ?>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-buttons mt-3 mb-3">
|
||||
<button type="submit" role="button" title="<?= __(
|
||||
'Sign in'
|
||||
) ?>" class="btn btn-login btn-primary">
|
||||
<?= __('Sign in') ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-center m-0"><?= __('Please log in to continue.') ?> <?= sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
$router->named('account:forgot'),
|
||||
__('Forgot your password?')
|
||||
) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -204,15 +204,13 @@ abstract class CestAbstract
|
|||
$I->amOnPage('/');
|
||||
$I->seeInCurrentUrl('/login');
|
||||
|
||||
$I->submitForm(
|
||||
'#login-form',
|
||||
[
|
||||
'username' => $this->login_username,
|
||||
'password' => $this->login_password,
|
||||
]
|
||||
);
|
||||
$I->sendPost('/login', [
|
||||
'username' => $this->login_username,
|
||||
'password' => $this->login_password,
|
||||
]);
|
||||
|
||||
$I->seeInSource('Logged In');
|
||||
$I->amOnPage('/');
|
||||
$I->seeInCurrentUrl('/dashboard');
|
||||
}
|
||||
|
||||
protected function testCrudApi(
|
||||
|
|
|
@ -37,8 +37,8 @@ class Frontend_SetupCest extends CestAbstract
|
|||
|
||||
$I->amOnPage('/login');
|
||||
|
||||
$I->submitForm(
|
||||
'#login-form',
|
||||
$I->sendPost(
|
||||
'/login',
|
||||
[
|
||||
'username' => $this->login_username,
|
||||
'password' => $this->login_password,
|
||||
|
|
Loading…
Reference in New Issue