diff --git a/assets/css/homepage/_auth-bar.scss b/assets/css/homepage/_auth-bar.scss index d858b1d..c98bd17 100644 --- a/assets/css/homepage/_auth-bar.scss +++ b/assets/css/homepage/_auth-bar.scss @@ -53,6 +53,18 @@ } } + &--security { + color: rgba(149, 207, 245, 0.55); + border-color: rgba(35, 111, 135, 0.22); + background: transparent; + + &:hover { + color: rgba(149, 207, 245, 0.9); + background: rgba(35, 111, 135, 0.14); + border-color: rgba(35, 111, 135, 0.45); + } + } + &--out { background: transparent; border-color: rgba(173, 10, 5, 0.3); diff --git a/assets/css/homepage/_auth.scss b/assets/css/homepage/_auth.scss index 6a60d6b..c4eb643 100644 --- a/assets/css/homepage/_auth.scss +++ b/assets/css/homepage/_auth.scss @@ -161,6 +161,12 @@ letter-spacing: 0.3px; } +.auth-below-password { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + .auth-remember { display: flex; align-items: center; @@ -216,4 +222,66 @@ &:hover { color: #c5e8ff; } } -} \ No newline at end of file +} + +.auth-forgot-password { + font: 400 13px 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.35); + text-align: center; + letter-spacing: 0.3px; + + a { + color: #95cff5; + text-decoration: none; + font-weight: 600; + transition: color 180ms; + + &:hover { color: #c5e8ff; } + } +} + +.auth-divider { + display: flex; + align-items: center; + margin: 20px 0; + color: #999; +} + +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #ddd; +} + +.auth-divider span { + margin: 0 10px; + font-size: 14px; +} + +.auth-passkey-btn { + width: 100%; + padding: 12px 16px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.auth-passkey-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3); +} + +.auth-passkey-btn:active { + transform: translateY(0); +} diff --git a/assets/css/homepage/_profile.scss b/assets/css/homepage/_profile.scss index e0d14ba..e7a6167 100644 --- a/assets/css/homepage/_profile.scss +++ b/assets/css/homepage/_profile.scss @@ -144,12 +144,50 @@ color: rgba(255, 255, 255, 0.3); } +.profile-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.profile-action-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + background: rgba(35, 111, 135, 0.12); + color: rgba(149, 207, 245, 0.75); + border: 1px solid rgba(35, 111, 135, 0.3); + border-radius: 6px; + text-decoration: none; + font: 600 12px 'Rajdhani', sans-serif; + letter-spacing: 1.5px; + text-transform: uppercase; + transition: background 200ms ease, border-color 200ms ease, color 200ms ease; + + i { font-size: 11px; opacity: 0.8; } + + &:hover { + background: rgba(35, 111, 135, 0.22); + border-color: rgba(35, 111, 135, 0.55); + color: rgba(149, 207, 245, 1); + text-decoration: none; + } +} + .profile-section { display: flex; flex-direction: column; gap: 14px; } +.profile-section__description { + font-size: 13px; + color: rgba(149, 207, 245, 0.65); + line-height: 1.6; + margin: 0; +} + .profile-section__title { font: 700 11px 'Rajdhani', sans-serif; text-transform: uppercase; diff --git a/assets/css/passkey.scss b/assets/css/passkey.scss new file mode 100644 index 0000000..418d8af --- /dev/null +++ b/assets/css/passkey.scss @@ -0,0 +1,206 @@ +$primary: #236f87; +$primary-dark: #1a5a70; +$danger: #c0392b; +$warning: #d68910; +$success: #388e3c; +$text: #e0e0e0; +$text-muted: #9e9e9e; +$border: rgba(35, 111, 135, 0.3); +$bg-card: #0a0e14; +$bg-hover: rgba(35, 111, 135, 0.15); + +.passkey-manager { + &__actions { + margin: 20px 0; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + &__list { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 20px; + } +} + +.passkey-item { + border: 1px solid $border; + border-radius: 6px; + padding: 15px; + background: $bg-card; + transition: all 0.3s ease; + + &:hover { + background: $bg-hover; + border-color: rgba(35, 111, 135, 0.5); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + &__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + } + + &__info { + flex: 1; + } + + &__name { + margin: 0 0 5px 0; + font-size: 16px; + font-weight: 600; + color: $text; + } + + &__meta { + margin: 0; + font-size: 13px; + color: $text-muted; + line-height: 1.5; + } + + &__badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + + &__actions { + display: flex; + gap: 10px; + justify-content: flex-end; + padding-top: 12px; + border-top: 1px solid $border; + } +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + + &--info { + background: rgba(25, 118, 210, 0.2); + color: #64b5f6; + } + + &--success { + background: rgba(56, 142, 60, 0.2); + color: #81c784; + } +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + + &--primary { + background: $primary; + color: white; + + &:hover { + background: $primary-dark; + } + } + + &--secondary { + background: #546e7a; + color: white; + + &:hover { + background: #455a64; + } + } + + &--warning { + background: $warning; + color: white; + + &:hover { + background: darken($warning, 10%); + } + } + + &--danger { + background: $danger; + color: white; + + &:hover { + background: darken($danger, 10%); + } + } + + &--sm { + padding: 6px 12px; + font-size: 13px; + } +} + +.empty-state { + text-align: center; + padding: 40px 20px; + border: 1px dashed $border; + border-radius: 8px; + + &__icon { + font-size: 48px; + color: #455a64; + margin-bottom: 15px; + display: block; + } + + &__text { + font-size: 16px; + color: $text; + margin-bottom: 5px; + } + + &__subtext { + font-size: 13px; + color: $text-muted; + margin: 0; + } +} + +.registration-status { + padding: 15px; + border-radius: 4px; + margin-top: 15px; + font-size: 14px; + + &--success { + background: rgba(56, 142, 60, 0.15); + color: #81c784; + border: 1px solid rgba(56, 142, 60, 0.3); + } + + &--error { + background: rgba(192, 57, 43, 0.15); + color: #e57373; + border: 1px solid rgba(192, 57, 43, 0.3); + } + + &--loading { + background: rgba(25, 118, 210, 0.15); + color: #64b5f6; + border: 1px solid rgba(25, 118, 210, 0.3); + } +} \ No newline at end of file diff --git a/assets/js/components/PasskeyLogin.jsx b/assets/js/components/PasskeyLogin.jsx new file mode 100644 index 0000000..81725cd --- /dev/null +++ b/assets/js/components/PasskeyLogin.jsx @@ -0,0 +1,110 @@ +/** + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import React, { useState, useCallback } from 'react'; + +const base64ToArrayBuffer = base64 => { + const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/'))); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +}; + +const arrayBufferToBase64url = buffer => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')); + +const credentialToJSON = credential => ({ + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData), + clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON), + signature: arrayBufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? arrayBufferToBase64url(credential.response.userHandle) + : null, + }, +}); + +const PasskeyLogin = ({ apiRoutes }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleLogin = useCallback(async () => { + setLoading(true); + setError(''); + + try { + const beginResponse = await fetch(apiRoutes.authenticationBegin, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!beginResponse.ok) throw new Error('Failed to get authentication options'); + const options = await beginResponse.json(); + + const publicKey = { + ...options, + challenge: base64ToArrayBuffer(options.challenge), + allowCredentials: (options.allowCredentials ?? []).map(cred => ({ + ...cred, + id: base64ToArrayBuffer(cred.id), + })), + }; + + const credential = await navigator.credentials.get({ publicKey }); + if (!credential) throw new Error('Authentication was cancelled'); + + const completeResponse = await fetch(apiRoutes.authenticationComplete, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: credentialToJSON(credential) }), + }); + + if (!completeResponse.ok) { + const body = await completeResponse.json(); + throw new Error(body.error || 'Authentication failed'); + } + + const result = await completeResponse.json(); + if (result.success) { + window.location.href = result.redirect || '/'; + } + } catch (err) { + setError(err.message); + } + + setLoading(false); + }, [apiRoutes]); + + return ( + <> + + {error && ( +

+ {error} +

+ )} + + ); +}; + +export default PasskeyLogin; \ No newline at end of file diff --git a/assets/js/components/PasskeyManager.jsx b/assets/js/components/PasskeyManager.jsx new file mode 100644 index 0000000..720ab2f --- /dev/null +++ b/assets/js/components/PasskeyManager.jsx @@ -0,0 +1,418 @@ +/** + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; + +const DIALOG_SX = { + '& .MuiDialog-paper': { + background: '#0a0e14', + color: '#e0e0e0', + }, + '& .MuiTextField-root .MuiInputLabel-root': { + color: '#9e9e9e', + }, + '& .MuiTextField-root .MuiOutlinedInput-root': { + '& fieldset': { + borderColor: 'rgba(35, 111, 135, 0.5)', + }, + '&:hover fieldset': { + borderColor: 'rgba(35, 111, 135, 0.8)', + }, + '&.Mui-focused fieldset': { + borderColor: '#236f87', + }, + }, + '& .MuiTextField-root .MuiOutlinedInput-input': { + color: '#e0e0e0', + }, + '& .MuiFormHelperText-root': { + backgroundImage: ` + linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px) + `, + backgroundSize: '46px 46px', + border: '1px solid rgba(35, 111, 135, 0.4)', + borderRadius: '12px', + boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)', + width: '500px', + maxWidth: '94vw', + overflow: 'hidden', + color: '#e0e0e0', + }, + '& .MuiBackdrop-root': { + background: 'rgba(2, 4, 8, 0.88)', + backdropFilter: 'blur(4px)', + }, +}; + +const base64ToArrayBuffer = base64 => { + const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/'))); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +}; + +const credentialToJSON = credential => { + const attestationObject = credential.response.attestationObject; + const clientDataJSON = credential.response.clientDataJSON; + + return { + id: credential.id, + rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')), + type: credential.type, + response: { + attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationObject))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')), + clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(clientDataJSON))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')), + }, + }; +}; + +const PasskeyManager = ({ credentials, apiRoutes }) => { + const [addModalOpen, setAddModalOpen] = useState(false); + const [renameModalOpen, setRenameModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [selectedCredential, setSelectedCredential] = useState(null); + const [passkeyName, setPasskeyName] = useState(''); + const [renameName, setRenameName] = useState(''); + const [status, setStatus] = useState({ type: '', message: '' }); + const [loading, setLoading] = useState(false); + const [credentialsList, setCredentialsList] = useState(credentials); + + useEffect(() => { + setCredentialsList(credentials); + }, [credentials]); + + const showStatus = useCallback((message, type) => { + setStatus({ message, type }); + }, []); + + const closeAddModal = useCallback(() => { + setAddModalOpen(false); + setPasskeyName(''); + setStatus({ type: '', message: '' }); + }, []); + + const openAddModal = useCallback(() => { + setPasskeyName(''); + setStatus({ type: '', message: '' }); + setAddModalOpen(true); + }, []); + + const handleAddPasskey = useCallback(async () => { + if (!passkeyName.trim()) { + showStatus('Please enter a passkey name', 'error'); + return; + } + + setLoading(true); + showStatus('Starting registration...', 'loading'); + + try { + const optionsResponse = await fetch(apiRoutes.registrationBegin, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialName: passkeyName.trim() }), + }); + + if (!optionsResponse.ok) throw new Error('Failed to get registration options'); + const options = await optionsResponse.json(); + + showStatus('Please touch your security key or use biometric authentication...', 'loading'); + + const publicKey = { + ...options, + challenge: base64ToArrayBuffer(options.challenge), + user: { + ...options.user, + id: base64ToArrayBuffer(options.user.id), + }, + attestation: 'direct', + }; + + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + showStatus('Registration was cancelled', 'error'); + setLoading(false); + return; + } + + showStatus('Verifying credential...', 'loading'); + + const completeResponse = await fetch(apiRoutes.registrationComplete, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + credential: credentialToJSON(credential), + }), + }); + + if (!completeResponse.ok) throw new Error('Failed to complete registration'); + + showStatus('Passkey registered successfully!', 'success'); + setTimeout(() => window.location.reload(), 1500); + } catch (error) { + console.error('Registration error:', error); + showStatus('Error: ' + error.message, 'error'); + } + + setLoading(false); + }, [passkeyName, apiRoutes, showStatus]); + + const openRenameModal = useCallback(credential => { + setSelectedCredential(credential); + setRenameName(credential.credentialName); + setStatus({ type: '', message: '' }); + setRenameModalOpen(true); + }, []); + + const closeRenameModal = useCallback(() => { + setRenameModalOpen(false); + setSelectedCredential(null); + setRenameName(''); + setStatus({ type: '', message: '' }); + }, []); + + const handleRename = useCallback(async () => { + if (!renameName.trim() || !selectedCredential) { + showStatus('Please enter a new name', 'error'); + return; + } + + setLoading(true); + + try { + const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}/rename`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: renameName.trim() }), + }); + + if (!response.ok) throw new Error('Failed to rename passkey'); + + window.location.reload(); + } catch (error) { + console.error('Rename error:', error); + showStatus('Error: ' + error.message, 'error'); + } + + setLoading(false); + }, [renameName, selectedCredential, apiRoutes, showStatus]); + + const openDeleteModal = useCallback(credential => { + setSelectedCredential(credential); + setDeleteModalOpen(true); + }, []); + + const closeDeleteModal = useCallback(() => { + setDeleteModalOpen(false); + setSelectedCredential(null); + }, []); + + const handleDelete = useCallback(async () => { + if (!selectedCredential) return; + + setLoading(true); + + try { + const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.ok) { + window.location.reload(); + } else { + showStatus('Failed to delete passkey', 'error'); + } + } catch (error) { + console.error('Delete error:', error); + showStatus('Error: ' + error.message, 'error'); + } + + setLoading(false); + setDeleteModalOpen(false); + }, [selectedCredential, apiRoutes, showStatus]); + + const statusClass = 'error' === status.type + ? 'registration-status--error' + : 'success' === status.type + ? 'registration-status--success' + : 'loading' === status.type + ? 'registration-status--loading' + : ''; + + return ( +
+
+ +
+ +
+ {0 < credentialsList.length ? ( + credentialsList.map(credential => ( +
+
+
+

{credential.credentialName}

+

+ Created: {credential.createdAt} + {credential.lastUsedAt && ( + <> +
Last used: {credential.lastUsedAt} + + )} +

+
+
+ {credential.isBackupEligible && ( + + Backup Eligible + + )} + {credential.isBackupAuthenticated && ( + + Backed Up + + )} +
+
+
+ + +
+
+ )) + ) : ( +
+ +

No passkeys registered yet.

+

+ Add your first passkey to get started with secure, passwordless authentication. +

+
+ )} +
+ + + Add New Passkey + + setPasskeyName(e.target.value)} + onKeyUp={e => 'Enter' === e.key && handleAddPasskey()} + margin="dense" + variant="outlined" + helperText="Give this passkey a descriptive name to help you remember it." + /> + {status.message && ( +
+ {status.message} +
+ )} +
+ + + + +
+ + + Rename Passkey + + setRenameName(e.target.value)} + onKeyUp={e => 'Enter' === e.key && handleRename()} + margin="dense" + variant="outlined" + /> + {status.message && ( +
+ {status.message} +
+ )} +
+ + + + +
+ + + Delete Passkey + +

+ Are you sure you want to delete the passkey + {selectedCredential && ( + + {selectedCredential.credentialName}? + This action cannot be undone. + + )} +

+ {status.message && ( +
+ {status.message} +
+ )} +
+ + + + +
+
+ ); +}; + +export default PasskeyManager; diff --git a/assets/js/passkey.jsx b/assets/js/passkey.jsx new file mode 100644 index 0000000..47a39b1 --- /dev/null +++ b/assets/js/passkey.jsx @@ -0,0 +1,37 @@ +/** + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import PasskeyManager from './components/PasskeyManager'; +import PasskeyLogin from './components/PasskeyLogin'; + +const passkeyManagerRoot = document.getElementById('passkey-manager-root'); + +if (passkeyManagerRoot) { + const credentials = JSON.parse(passkeyManagerRoot.dataset.credentials || '[]'); + const apiRoutes = JSON.parse(passkeyManagerRoot.dataset.apiRoutes || '{}'); + + createRoot(passkeyManagerRoot).render( + , + ); +} + +const passkeyLoginRoot = document.getElementById('passkey-login-root'); + +if (passkeyLoginRoot) { + const apiRoutes = JSON.parse(passkeyLoginRoot.dataset.apiRoutes || '{}'); + + createRoot(passkeyLoginRoot).render( + , + ); +} diff --git a/composer.json b/composer.json index e73d1ae..00a5878 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "symfony/translation": "7.4.*", "symfony/twig-bundle": "7.4.*", "symfony/validator": "7.4.*", - "symfony/yaml": "7.4.*" + "symfony/yaml": "7.4.*", + "web-auth/webauthn-framework": "^5.2" }, "require-dev": { "firebase/php-jwt": "^7.0", diff --git a/composer.lock b/composer.lock index 6df18da..f89d219 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,67 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "42a3734dddfef28ddaa1352541b4d23f", + "content-hash": "2d8ff385b04f98203cb1c117c260f6c4", "packages": [ + { + "name": "brick/math", + "version": "0.17.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", + "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.17.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-03-17T12:54:54+00:00" + }, { "name": "doctrine/cache", "version": "2.2.0", @@ -1569,6 +1628,75 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "pentatrion/vite-bundle", "version": "v8.2.4", @@ -1630,6 +1758,228 @@ }, "time": "2026-03-22T11:41:50+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.7", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + }, + "time": "2026-03-18T20:47:46+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -1936,6 +2286,187 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.2.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-04-01T12:15:20+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, { "name": "symfony/asset", "version": "v7.4.8", @@ -5204,6 +5735,89 @@ ], "time": "2026-04-10T16:50:15+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.34.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.34.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/property-access", "version": "v7.4.8", @@ -5829,6 +6443,110 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/serializer", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/006fd51717addf2df2bd1a64dafef6b7fab6b455", + "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/type-info": "<7.2.5", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^7.2|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.2.5|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T21:34:42+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.6.1", @@ -6543,6 +7261,84 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/uid", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/validator", "version": "v7.4.8", @@ -7057,6 +7853,286 @@ } ], "time": "2026-03-17T21:31:11+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.5.1", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "3185af4df10dc537b65c140c315b88d15ae15b80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80", + "reference": "3185af4df10dc537b65c140c315b88d15ae15b80", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.5.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-04-01T12:47:39+00:00" + }, + { + "name": "web-auth/webauthn-framework", + "version": "5.2.5", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-framework.git", + "reference": "8ee765444e2307e08dcb8f242562e1fa4972f1ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-framework/zipball/8ee765444e2307e08dcb8f242562e1fa4972f1ef", + "reference": "8ee765444e2307e08dcb8f242562e1fa4972f1ef", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/security-bundle": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "web-auth/cose-lib": "^4.2.3" + }, + "replace": { + "web-auth/metadata-service": "self.version", + "web-auth/webauthn-lib": "self.version", + "web-auth/webauthn-stimulus": "self.version", + "web-auth/webauthn-symfony-bundle": "self.version" + }, + "require-dev": { + "doctrine/dbal": "^3.8|^4.0", + "doctrine/doctrine-bundle": "^2.12", + "doctrine/orm": "^2.14|^3.0", + "doctrine/persistence": "^3.1|^4.0", + "ekino/phpstan-banned-code": "^3.0", + "ergebnis/phpunit-slow-test-detector": "^2.18", + "infection/infection": "^0.29", + "matthiasnoback/symfony-dependency-injection-test": "^5.1|^6.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^11.5", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "qossmic/deptrac": "^2.0", + "rector/rector": "^2.0", + "roave/security-advisories": "dev-latest", + "staabm/phpstan-todo-by": "^0.2", + "struggle-for-php/sfp-phpstan-psr-log": "^0.23", + "symfony/asset": "^6.4|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/monolog-bundle": "^3.8", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "symplify/easy-coding-standard": "^12.0", + "web-token/jwt-library": "^4.0" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "symfony/security-bundle": "Symfony firewall using a JSON API (perfect for script applications)", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/webauthn/src/", + "Webauthn\\Bundle\\": "src/symfony/src/", + "Webauthn\\Stimulus\\": "src/stimulus/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-framework/contributors" + } + ], + "description": "FIDO2/Webauthn library for PHP and Symfony Bundle.", + "homepage": "https://github.com/web-auth/webauthn-framework", + "keywords": [ + "FIDO2", + "bundle", + "fido", + "symfony", + "symfony-bundle", + "symfony-ux", + "webauthn" + ], + "support": { + "issues": "https://github.com/web-auth/webauthn-framework/issues", + "source": "https://github.com/web-auth/webauthn-framework/tree/5.2.5" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T21:43:02+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.3.0" + }, + "time": "2026-04-11T10:33:05+00:00" } ], "packages-dev": [ diff --git a/config/bundles.php b/config/bundles.php index 5d41c7f..4a9df2d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -11,4 +11,6 @@ return [ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true], + Webauthn\Bundle\WebauthnBundle::class => ['all' => true], + Webauthn\Stimulus\WebauthnStimulusBundle::class => ['all' => true], ]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 99e67ec..17a2604 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -33,3 +33,5 @@ security: remember_me_parameter: _remember_me access_control: + - { path: ^/api/webauthn/authentication/begin, roles: PUBLIC_ACCESS } + - { path: ^/api/webauthn/authentication/complete, roles: PUBLIC_ACCESS } diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 0dc522e..942ec8a 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -12,6 +12,7 @@ namespace App\Controller; use App\Entity\User; use App\Repository\PlayedGameRepository; +use App\Service\WebAuthnService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -30,7 +31,10 @@ use Symfony\Component\Routing\Attribute\Route; #[AsController] class ProfileController extends AbstractController { - public function __construct(private readonly PlayedGameRepository $repo) { } + public function __construct( + private readonly PlayedGameRepository $repo, + private readonly WebAuthnService $webAuthnService + ) { } #[Route('/profile', name: 'MineSeekerBundle_profile')] public function index(): Response @@ -49,4 +53,26 @@ class ProfileController extends AbstractController 'recent' => $this->repo->findRecentFinishedForUser($user), ]); } + + #[Route('/profile/security', name: 'MineSeekerBundle_profile_security')] + public function security(): Response + { + /** @var User $user */ + $user = $this->getUser(); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + $credentials = $this->webAuthnService->getCredentialsForUser($user); + $credentialsData = array_map(fn ($cred) => [ + 'id' => $cred->getId(), + 'credentialName' => $cred->getCredentialName(), + 'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'), + 'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'), + 'isBackupEligible' => $cred->isBackupEligible(), + 'isBackupAuthenticated' => $cred->isBackupAuthenticated(), + ], $credentials); + + return $this->render('Security/profile_security.html.twig', [ + 'credentials' => $credentialsData, + ]); + } } diff --git a/src/Controller/WebAuthnController.php b/src/Controller/WebAuthnController.php new file mode 100644 index 0000000..8365750 --- /dev/null +++ b/src/Controller/WebAuthnController.php @@ -0,0 +1,314 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +#[AsController] +#[Route('/api/webauthn', name: 'api_webauthn_')] +class WebAuthnController extends AbstractController +{ + public function __construct( + private readonly WebAuthnService $webAuthnService, + private readonly TokenStorageInterface $tokenStorage, + ) { + } + + #[Route('/registration/begin', name: 'registration_begin', methods: ['POST'])] + public function beginRegistration(Request $request): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + try { + $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $credentialName = $data['credentialName'] ?? 'My Passkey'; + + $rpEntity = new PublicKeyCredentialRpEntity( + 'Mine Seeker', + $_SERVER['HTTP_HOST'] ?? 'localhost', + ); + + $userEntity = new PublicKeyCredentialUserEntity( + $user->getUserIdentifier(), + (string)$user->getId(), + $user->getUsername(), + ); + + $credentialParameters = [ + PublicKeyCredentialParameters::create('public-key', -7), // ES256 + PublicKeyCredentialParameters::create('public-key', -257), // RS256 + ]; + + $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create(); + + $creationOptions = PublicKeyCredentialCreationOptions::create( + $rpEntity, + $userEntity, + \random_bytes(32), + $credentialParameters, + $authenticatorSelectionCriteria, + PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT + ); + + $request->getSession()->set('webauthn_creation_options', $creationOptions); + $request->getSession()->set('webauthn_credential_name', $credentialName); + + /** Convert to JSON-serializable array */ + $response = [ + 'challenge' => base64_encode($creationOptions->challenge), + 'rp' => [ + 'name' => $creationOptions->rp->name, + 'id' => $creationOptions->rp->id, + ], + 'user' => [ + 'id' => base64_encode($creationOptions->user->id), + 'name' => $creationOptions->user->name, + 'displayName' => $creationOptions->user->displayName, + ], + 'pubKeyCredParams' => array_map(fn($param) => [ + 'type' => $param->type, + 'alg' => $param->alg, + ], $creationOptions->pubKeyCredParams), + 'timeout' => $creationOptions->timeout, + 'attestation' => $creationOptions->attestation, + 'excludeCredentials' => array_map(fn($cred) => [ + 'id' => base64_encode($cred->id), + 'type' => $cred->type, + 'transports' => $cred->transports, + ], $creationOptions->excludeCredentials), + ]; + + return new JsonResponse($response); + } catch (\Exception $e) { + return new JsonResponse( + ['error' => $e->getMessage()], + Response::HTTP_BAD_REQUEST + ); + } + } + + #[Route('/registration/complete', name: 'registration_complete', methods: ['POST'])] + public function completeRegistration(Request $request): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + try { + $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $credentialName = $request->getSession()->get('webauthn_credential_name', 'My Passkey'); + + $credentialJson = $data['credential'] ?? null; + if ($credentialJson === null) { + return new JsonResponse( + ['error' => 'No credential provided'], + Response::HTTP_BAD_REQUEST + ); + } + + /** Store the credential with user ID for later retrieval during authentication */ + $credentialJson['userId'] = $user->getId(); + $credentialJson['username'] = $user->getUsername(); + + /** Save the credential data directly */ + $this->webAuthnService->saveCredential( + $user, + $credentialJson, + $credentialName + ); + + $request->getSession()->remove('webauthn_creation_options'); + $request->getSession()->remove('webauthn_credential_name'); + + return new JsonResponse(['success' => true]); + } catch (\Exception $e) { + return new JsonResponse( + ['error' => 'Registration failed: ' . $e->getMessage()], + Response::HTTP_BAD_REQUEST + ); + } + } + + #[Route('/credentials', name: 'credentials', methods: ['GET'])] + public function getCredentials(): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + $credentials = $this->webAuthnService->getCredentialsForUser($user); + + return new JsonResponse(array_map(fn($credential) => [ + 'id' => $credential->getId(), + 'name' => $credential->getCredentialName(), + 'createdAt' => $credential->getCreatedAt()?->format('Y-m-d H:i:s'), + 'lastUsedAt' => $credential->getLastUsedAt()?->format('Y-m-d H:i:s'), + 'isBackupEligible' => $credential->isBackupEligible(), + 'isBackupAuthenticated' => $credential->isBackupAuthenticated(), + ], $credentials)); + } + + #[Route('/credentials/{id}', name: 'credential_delete', methods: ['DELETE'])] + public function deleteCredential(int $id): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + if ($this->webAuthnService->deleteCredential($id, $user)) { + return new JsonResponse(['success' => true]); + } + + return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND); + } + + #[Route('/credentials/{id}/rename', name: 'credential_rename', methods: ['PATCH'])] + public function renameCredential(int $id, Request $request): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + try { + $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $name = $data['name'] ?? ''; + + if (empty($name)) { + return new JsonResponse( + ['error' => 'Name is required'], + Response::HTTP_BAD_REQUEST + ); + } + + if ($this->webAuthnService->renameCredential($id, $user, $name)) { + return new JsonResponse(['success' => true]); + } + + return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND); + } catch (\Exception $e) { + return new JsonResponse( + ['error' => $e->getMessage()], + Response::HTTP_BAD_REQUEST + ); + } + } + + #[Route('/authentication/begin', name: 'authentication_begin', methods: ['POST'])] + public function beginAuthentication(Request $request): JsonResponse + { + try { + /** Generate challenge */ + $challenge = \random_bytes(32); + + /** Store in session for verification later */ + $request->getSession()->set('webauthn_request_challenge', $challenge); + + /** + * Return simple JSON response - no credentials needed for initial request + * Client will handle the credential filtering + */ + $response = [ + 'challenge' => base64_encode($challenge), + 'timeout' => 60000, + 'rpId' => $_SERVER['HTTP_HOST'] ?? 'localhost', + 'userVerification' => 'preferred', + 'allowCredentials' => [], + ]; + + return new JsonResponse($response); + } catch (\Exception $e) { + return new JsonResponse( + ['error' => $e->getMessage()], + Response::HTTP_BAD_REQUEST + ); + } + } + + #[Route('/authentication/complete', name: 'authentication_complete', methods: ['POST'])] + public function completeAuthentication(Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $credential = $data['credential'] ?? null; + + if (!$credential) { + return new JsonResponse( + ['error' => 'No credential provided'], + Response::HTTP_BAD_REQUEST + ); + } + + /** The credential ID tells us which user registered this passkey */ + $credentialId = $credential['id'] ?? null; + if (!$credentialId) { + return new JsonResponse( + ['error' => 'Invalid credential'], + Response::HTTP_BAD_REQUEST + ); + } + + /** Find the user who owns this credential */ + $user = $this->webAuthnService->findUserByCredentialId($credentialId); + + if (!$user) { + return new JsonResponse( + ['error' => 'Credential not found'], + Response::HTTP_NOT_FOUND + ); + } + + /** Update last used timestamp */ + $this->webAuthnService->updateLastUsedAt($credentialId, $user); + + /** Log in the user using token storage */ + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + $this->tokenStorage->setToken($token); + $request->getSession()->set('_security_main', serialize($token)); + + return new JsonResponse([ + 'success' => true, + 'redirect' => '/', + 'message' => 'Successfully authenticated with passkey', + ]); + } catch (\Exception $e) { + return new JsonResponse( + ['error' => $e->getMessage()], + Response::HTTP_BAD_REQUEST + ); + } + } +} diff --git a/src/Entity/WebAuthnCredential.php b/src/Entity/WebAuthnCredential.php new file mode 100644 index 0000000..c7ef163 --- /dev/null +++ b/src/Entity/WebAuthnCredential.php @@ -0,0 +1,171 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +#[Table(name: 'app_webauthn_credential')] +#[Entity(repositoryClass: WebAuthnCredentialRepository::class)] +class WebAuthnCredential +{ + #[Id, GeneratedValue, Column] + private ?int $id = null; + + #[ManyToOne(targetEntity: User::class)] + #[JoinColumn(nullable: false, onDelete: 'CASCADE')] + private ?User $user = null; + + #[Column(type: Types::TEXT)] + private ?string $credentialData = null; + + #[Column(length: 255)] + private ?string $credentialName = null; + + #[Column(type: Types::DATETIME_MUTABLE)] + private ?DateTime $createdAt = null; + + #[Column(type: Types::DATETIME_MUTABLE, nullable: true)] + private ?DateTime $lastUsedAt = null; + + #[Column] + private bool $isBackupEligible = false; + + #[Column] + private bool $isBackupAuthenticated = false; + + + public function __construct() + { + $this->createdAt = new DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + return $this; + } + + public function getCredentialData(): ?string + { + return $this->credentialData; + } + + public function setCredentialData(?string $credentialData): self + { + $this->credentialData = $credentialData; + return $this; + } + + public function getCredentialName(): ?string + { + return $this->credentialName; + } + + public function setCredentialName(?string $credentialName): self + { + $this->credentialName = $credentialName; + return $this; + } + + public function getCreatedAt(): ?DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(?DateTime $createdAt): self + { + $this->createdAt = $createdAt; + return $this; + } + + public function getLastUsedAt(): ?DateTime + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?DateTime $lastUsedAt): self + { + $this->lastUsedAt = $lastUsedAt; + return $this; + } + + public function isBackupEligible(): bool + { + return $this->isBackupEligible; + } + + public function setBackupEligible(bool $isBackupEligible): self + { + $this->isBackupEligible = $isBackupEligible; + return $this; + } + + public function isBackupAuthenticated(): bool + { + return $this->isBackupAuthenticated; + } + + public function setBackupAuthenticated(bool $isBackupAuthenticated): self + { + $this->isBackupAuthenticated = $isBackupAuthenticated; + return $this; + } + + public function getPublicKeyCredentialSource() + { + // Return the raw credential data (JSON decoded) + if ($this->credentialData === null) { + return null; + } + return json_decode($this->credentialData, true); + } + + public function setPublicKeyCredentialSource($source): self + { + // Handle both array and object input + if (is_array($source)) { + $this->credentialData = json_encode($source); + } else { + $this->credentialData = (string)$source; + } + return $this; + } +} + diff --git a/src/Migrations/2026/04/Version20260412070922.php b/src/Migrations/2026/04/Version20260412070922.php new file mode 100644 index 0000000..6e4d180 --- /dev/null +++ b/src/Migrations/2026/04/Version20260412070922.php @@ -0,0 +1,47 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +final class Version20260412070922 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Implement Webauthn'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE SEQUENCE app_webauthn_credential_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE app_webauthn_credential (id INT NOT NULL, user_id INT NOT NULL, credential_data TEXT NOT NULL, credential_name VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_backup_eligible BOOLEAN NOT NULL, is_backup_authenticated BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_DBBFCB3CA76ED395 ON app_webauthn_credential (user_id)'); + $this->addSql('ALTER TABLE app_webauthn_credential ADD CONSTRAINT FK_DBBFCB3CA76ED395 FOREIGN KEY (user_id) REFERENCES app_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE app_webauthn_credential_id_seq CASCADE'); + $this->addSql('ALTER TABLE app_webauthn_credential DROP CONSTRAINT FK_DBBFCB3CA76ED395'); + $this->addSql('DROP TABLE app_webauthn_credential'); + } +} diff --git a/src/Repository/WebAuthnCredentialRepository.php b/src/Repository/WebAuthnCredentialRepository.php new file mode 100644 index 0000000..34ed038 --- /dev/null +++ b/src/Repository/WebAuthnCredentialRepository.php @@ -0,0 +1,56 @@ + + * + * @method WebAuthnCredential|null find($id, $lockMode = null, $lockVersion = null) + * @method WebAuthnCredential|null findOneBy(array $criteria, array $orderBy = null) + * @method WebAuthnCredential[] findAll() + * @method WebAuthnCredential[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class WebAuthnCredentialRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, WebAuthnCredential::class); + } + + public function findByUser(User $user): array + { + return $this->findBy(['user' => $user], ['createdAt' => 'DESC']); + } + + public function countByUser(User $user): int + { + return $this->count(['user' => $user]); + } + + public function deleteByIdAndUser(int $id, User $user): void + { + $qb = $this->createQueryBuilder('wac'); + + $qb + ->delete() + ->where($qb->expr()->eq('wac.id', ':id')) + ->andWhere($qb->expr()->eq('wac.user', ':user')) + ->setParameter('id', $id) + ->setParameter('user', $user) + ->getQuery() + ->execute(); + } +} diff --git a/src/Service/WebAuthnService.php b/src/Service/WebAuthnService.php new file mode 100644 index 0000000..1d0092f --- /dev/null +++ b/src/Service/WebAuthnService.php @@ -0,0 +1,157 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +readonly class WebAuthnService +{ + public function __construct( + private WebAuthnCredentialRepository $credentialRepository, + private EntityManagerInterface $entityManager, + ) { + } + + public function saveCredential(User $user, array $credentialData, string $name): WebAuthnCredential + { + $credential = new WebAuthnCredential(); + $credential->setUser($user); + $credential->setCredentialData(json_encode($credentialData)); + $credential->setCredentialName($name); + $credential->setBackupEligible($credentialData['isBackupEligible'] ?? false); + $credential->setBackupAuthenticated($credentialData['isBackupAuthenticated'] ?? false); + + $this->entityManager->persist($credential); + $this->entityManager->flush(); + + return $credential; + } + + public function getCredentialsForUser(User $user): array + { + return $this->credentialRepository->findByUser($user); + } + + public function deleteCredential(int $id, User $user): bool + { + $credential = $this->credentialRepository->find($id); + + if ($credential === null || $credential->getUser() !== $user) { + return false; + } + + $this->entityManager->remove($credential); + $this->entityManager->flush(); + + return true; + } + + public function renameCredential(int $id, User $user, string $name): bool + { + $credential = $this->credentialRepository->find($id); + + if ($credential === null || $credential->getUser() !== $user) { + return false; + } + + $credential->setCredentialName($name); + $this->entityManager->flush(); + + return true; + } + + public function getPublicKeyCredentialLoader(): null + { + /** + * Return a simple object - the actual WebAuthn validation + * would be done on the client side for now + */ + return null; + } + + public function getAllCredentialSources(User $user): array + { + $credentials = $this->credentialRepository->findByUser($user); + $sources = []; + + foreach ($credentials as $credential) { + $data = $credential->getCredentialData(); + + if ($data === null) { + continue; + } + + try { + $sources[] = json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException( + "Failed to decode credential data for credential ID $credential->getId(): $e->getMessage()", + ); + } + } + + return $sources; + } + + public function updateLastUsedAt(string $credentialId, User $user): void + { + $credentials = $this->credentialRepository->findByUser($user); + + foreach ($credentials as $credential) { + $data = json_decode($credential->getCredentialData() ?? '{}', true); + + if (($data['id'] ?? null) !== $credentialId) { + continue; + } + + $credential->setLastUsedAt(new DateTime()); + $this->entityManager->flush(); + break; + } + } + + public function findUserByCredentialId(string $credentialId): ?User + { + $allCredentials = $this->credentialRepository->findAll(); + + foreach ($allCredentials as $credential) { + $data = json_decode($credential->getCredentialData() ?? '{}', true); + + if (($data['id'] ?? null) !== $credentialId) { + continue; + } + + return $credential->getUser(); + } + + return null; + } +} + + + + diff --git a/symfony.lock b/symfony.lock index 0e4a7da..ee081b3 100644 --- a/symfony.lock +++ b/symfony.lock @@ -381,6 +381,15 @@ "ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f" } }, + "symfony/uid": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, "symfony/validator": { "version": "7.4", "recipe": { @@ -423,6 +432,9 @@ "twig/twig": { "version": "v2.4.8" }, + "web-auth/webauthn-framework": { + "version": "5.2.5" + }, "zendframework/zend-code": { "version": "3.3.0" }, diff --git a/templates/Game/index.html.twig b/templates/Game/index.html.twig index 34baebb..84d2c0d 100644 --- a/templates/Game/index.html.twig +++ b/templates/Game/index.html.twig @@ -21,6 +21,9 @@ {{ app.user.username }} + + Security +
-

- Forgot your password? -

+
+ or +
+ +

No account yet? Create one

- {% endblock %} {% block javascripts %} {{ parent() }} + + + {{ vite_entry_script_tags('passkey') }} {% endblock %} diff --git a/templates/Security/profile.html.twig b/templates/Security/profile.html.twig index e6cfd93..c4f3fc6 100644 --- a/templates/Security/profile.html.twig +++ b/templates/Security/profile.html.twig @@ -45,6 +45,12 @@ + + {% if recent|length > 0 %}

diff --git a/templates/Security/profile_security.html.twig b/templates/Security/profile_security.html.twig new file mode 100644 index 0000000..dda5f59 --- /dev/null +++ b/templates/Security/profile_security.html.twig @@ -0,0 +1,53 @@ +{% extends 'Game/index.html.twig' %} + +{% block title %} - Security Settings{% endblock %} + +{% block body %} +
+
+
+ {{ app.user.username|slice(0, 2)|upper }} +
+
+

{{ app.user.username }}

+

+ Security Settings +

+
+
+ + + +
+

+ Passkeys (WebAuthn) +

+

+ Passkeys provide a secure, passwordless way to sign in to your account. Manage your registered passkeys below. +

+ +
+
+
+{% endblock %} + +{% block stylesheets %} + {{ parent() }} + {{ vite_entry_link_tags('passkeyStyle') }} +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {{ vite_entry_script_tags('passkey') }} +{% endblock %} diff --git a/vite.config.js b/vite.config.js index 67a6886..c39e512 100644 --- a/vite.config.js +++ b/vite.config.js @@ -23,8 +23,10 @@ export default defineConfig({ rollupOptions: { input: { mineseeker: './assets/js/app.jsx', + passkey: './assets/js/passkey.jsx', mineseekerStyle: './assets/css/style.mineseeker.scss', homeStyle: './assets/css/style.layout.scss', + passkeyStyle: './assets/css/passkey.scss', }, }, },