Merge commit '98b31298b086a00a47efa35c3e2b81a0cb8b3f37'

This commit is contained in:
Buster Neece 2023-11-26 02:52:21 -06:00
parent 3b13030a73
commit ee9b7d88d4
No known key found for this signature in database
34 changed files with 1605 additions and 517 deletions

View File

@ -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

View File

@ -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",

47
composer.lock generated
View File

@ -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",

View File

@ -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'
);
}
);

View File

@ -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) {

View File

@ -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>

View File

@ -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>

View File

@ -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');

View File

@ -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();

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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),
[],
);

View File

@ -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>

View File

@ -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,
};
}

View File

@ -0,0 +1,5 @@
import initApp from "~/layout";
import useMinimalLayout from "~/layouts/MinimalLayout";
import Login from "~/components/Login.vue";
initApp(useMinimalLayout(Login));

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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'),
]
);
}
}

View File

@ -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);
}
}

View File

@ -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')
);
}
}

View File

@ -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(),
]
);

View File

@ -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,
]
);

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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;
}
}

View File

@ -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() . ')';

104
src/Entity/UserPasskey.php Normal file
View File

@ -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.');
}
}
}

View File

@ -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
);
}
}

View File

@ -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>

View File

@ -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(

View File

@ -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,