new: usr: implement the 2FA authentication (TOTP and backup codes) #4
This commit is contained in:
@@ -207,6 +207,74 @@
|
|||||||
&:active { transform: translateY(0); }
|
&:active { transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-cancel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: linear-gradient(to bottom, #1a1a1a 0%, #2d2d2d 55%, #404040 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #ffffff;
|
||||||
|
font: 700 16px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
padding: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 6px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
transition: all 220ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(to bottom, #2d2d2d 0%, #3d3d3d 55%, #505050 100%);
|
||||||
|
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.6);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
form {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&.auth-form {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-cancel-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.auth-cancel {
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-cancel-standalone {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-cancel--block {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-switch {
|
.auth-switch {
|
||||||
font: 400 13px 'Rajdhani', sans-serif;
|
font: 400 13px 'Rajdhani', sans-serif;
|
||||||
color: rgba(255, 255, 255, 0.35);
|
color: rgba(255, 255, 255, 0.35);
|
||||||
@@ -285,3 +353,78 @@
|
|||||||
.auth-passkey-btn:active {
|
.auth-passkey-btn:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-input--code {
|
||||||
|
font: 700 22px 'Courier New', monospace;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field-hint {
|
||||||
|
font: 400 12px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(149, 207, 245, 0.45);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card--wide {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-back {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
font: 500 13px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(149, 207, 245, 0.5);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
transition: color 180ms;
|
||||||
|
|
||||||
|
&:hover { color: rgba(149, 207, 245, 0.9); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofa-setup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
&__qr {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid rgba(35, 111, 135, 0.3);
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__manual {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__manual-label {
|
||||||
|
font: 400 12px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(149, 207, 245, 0.45);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__secret {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 700 14px 'Courier New', monospace;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: #95cff5;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,8 +32,7 @@
|
|||||||
font: 800 28px 'Rajdhani', sans-serif;
|
font: 800 28px 'Rajdhani', sans-serif;
|
||||||
color: rgba(149, 207, 245, 0.9);
|
color: rgba(149, 207, 245, 0.9);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
box-shadow:
|
box-shadow: 0 0 0 4px rgba(35, 111, 135, 0.08),
|
||||||
0 0 0 4px rgba(35, 111, 135, 0.08),
|
|
||||||
0 0 24px rgba(35, 111, 135, 0.2);
|
0 0 24px rgba(35, 111, 135, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +61,10 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
i { font-size: 11px; opacity: 0.7; }
|
i {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-role {
|
.profile-role {
|
||||||
@@ -74,7 +76,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
i { font-size: 10px; }
|
i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stats {
|
.profile-stats {
|
||||||
@@ -102,17 +106,26 @@
|
|||||||
|
|
||||||
&--win {
|
&--win {
|
||||||
border-color: rgba(42, 158, 96, 0.18);
|
border-color: rgba(42, 158, 96, 0.18);
|
||||||
&:hover { border-color: rgba(42, 158, 96, 0.45); }
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(42, 158, 96, 0.45);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--loss {
|
&--loss {
|
||||||
border-color: rgba(173, 10, 5, 0.18);
|
border-color: rgba(173, 10, 5, 0.18);
|
||||||
&:hover { border-color: rgba(173, 10, 5, 0.45); }
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(173, 10, 5, 0.45);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--bomb {
|
&--bomb {
|
||||||
border-color: rgba(246, 125, 82, 0.18);
|
border-color: rgba(246, 125, 82, 0.18);
|
||||||
&:hover { border-color: rgba(246, 125, 82, 0.45); }
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(246, 125, 82, 0.45);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +134,17 @@
|
|||||||
color: rgba(255, 255, 255, 0.15);
|
color: rgba(255, 255, 255, 0.15);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|
||||||
.profile-stat--win & { color: rgba(94, 232, 154, 0.3); }
|
.profile-stat--win & {
|
||||||
.profile-stat--loss & { color: rgba(246, 125, 82, 0.3); }
|
color: rgba(94, 232, 154, 0.3);
|
||||||
.profile-stat--bomb & { color: rgba(246, 125, 82, 0.25); }
|
}
|
||||||
|
|
||||||
|
.profile-stat--loss & {
|
||||||
|
color: rgba(246, 125, 82, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--bomb & {
|
||||||
|
color: rgba(246, 125, 82, 0.25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat__value {
|
.profile-stat__value {
|
||||||
@@ -132,9 +153,17 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
.profile-stat--win & { color: #5ee89a; }
|
.profile-stat--win & {
|
||||||
.profile-stat--loss & { color: #f67d52; }
|
color: #5ee89a;
|
||||||
.profile-stat--bomb & { color: rgba(246, 125, 82, 0.8); }
|
}
|
||||||
|
|
||||||
|
.profile-stat--loss & {
|
||||||
|
color: #f67d52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--bomb & {
|
||||||
|
color: rgba(246, 125, 82, 0.8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat__label {
|
.profile-stat__label {
|
||||||
@@ -165,7 +194,10 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
|
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
|
||||||
|
|
||||||
i { font-size: 11px; opacity: 0.8; }
|
i {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(35, 111, 135, 0.22);
|
background: rgba(35, 111, 135, 0.22);
|
||||||
@@ -179,6 +211,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-section__description {
|
.profile-section__description {
|
||||||
@@ -189,7 +222,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-section__title {
|
.profile-section__title {
|
||||||
font: 700 11px 'Rajdhani', sans-serif;
|
font: 700 18px 'Rajdhani', sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
color: rgba(149, 207, 245, 0.45);
|
color: rgba(149, 207, 245, 0.45);
|
||||||
@@ -197,7 +230,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
i { opacity: 0.7; }
|
i {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-games {
|
.profile-games {
|
||||||
@@ -219,11 +254,21 @@
|
|||||||
font: 500 14px 'Rajdhani', sans-serif;
|
font: 500 14px 'Rajdhani', sans-serif;
|
||||||
transition: background 180ms ease;
|
transition: background 180ms ease;
|
||||||
|
|
||||||
&:hover { background: rgba(255, 255, 255, 0.045); }
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
&--win { border-left-color: rgba(42, 158, 96, 0.55); }
|
&--win {
|
||||||
&--loss { border-left-color: rgba(173, 10, 5, 0.55); }
|
border-left-color: rgba(42, 158, 96, 0.55);
|
||||||
&--draw { border-left-color: rgba(149, 207, 245, 0.25); }
|
}
|
||||||
|
|
||||||
|
&--loss {
|
||||||
|
border-left-color: rgba(173, 10, 5, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--draw {
|
||||||
|
border-left-color: rgba(149, 207, 245, 0.25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-game__badge {
|
.profile-game__badge {
|
||||||
@@ -236,9 +281,20 @@
|
|||||||
font: 800 10px 'Rajdhani', sans-serif;
|
font: 800 10px 'Rajdhani', sans-serif;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|
||||||
.profile-game--win & { background: rgba(42, 158, 96, 0.18); color: #5ee89a; }
|
.profile-game--win & {
|
||||||
.profile-game--loss & { background: rgba(173, 10, 5, 0.18); color: #f67d52; }
|
background: rgba(42, 158, 96, 0.18);
|
||||||
.profile-game--draw & { background: rgba(149, 207, 245, 0.1); color: rgba(149, 207, 245, 0.65); }
|
color: #5ee89a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-game--loss & {
|
||||||
|
background: rgba(173, 10, 5, 0.18);
|
||||||
|
color: #f67d52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-game--draw & {
|
||||||
|
background: rgba(149, 207, 245, 0.1);
|
||||||
|
color: rgba(149, 207, 245, 0.65);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-game__score {
|
.profile-game__score {
|
||||||
@@ -299,6 +355,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: color 180ms;
|
transition: color 180ms;
|
||||||
|
|
||||||
&:hover { color: #c5e8ff; }
|
&:hover {
|
||||||
|
color: #c5e8ff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,88 @@
|
|||||||
|
.twofa-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font: 600 13px 'Rajdhani', sans-serif;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
&--enabled {
|
||||||
|
background: rgba(42, 158, 96, 0.12);
|
||||||
|
border: 1px solid rgba(42, 158, 96, 0.3);
|
||||||
|
color: #5ee89a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofa-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofa-backup-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
font: 500 13px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(149, 207, 245, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofa-backup-reveal {
|
||||||
|
background: rgba(246, 125, 82, 0.07);
|
||||||
|
border: 1px solid rgba(246, 125, 82, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
|
||||||
|
&__warning {
|
||||||
|
font: 600 12px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: #f6a060;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofa-backup-code {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(246, 125, 82, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 700 13px 'Courier New', monospace;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #e0c890;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
$primary: #236f87;
|
$primary: #236f87;
|
||||||
$primary-dark: #1a5a70;
|
$primary-dark: #1a5a70;
|
||||||
$danger: #c0392b;
|
$danger: #c0392b;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
|
|
||||||
const wInit = (revealedCells = []) => {
|
const wInit = (revealedCells = []) => {
|
||||||
setGridReady(true);
|
setGridReady(true);
|
||||||
showOverlay('We are waiting for your opponent...', gameAssoc ? (
|
showOverlay('Choose an opponent!', gameAssoc ? (
|
||||||
<WaitingOverlayContent
|
<WaitingOverlayContent
|
||||||
shareUrl={`${window.location.href}/${gameAssoc}`}
|
shareUrl={`${window.location.href}/${gameAssoc}`}
|
||||||
currentGameAssoc={gameAssoc}
|
currentGameAssoc={gameAssoc}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
"doctrine/doctrine-bundle": ">=2.11 <2.14",
|
"doctrine/doctrine-bundle": ">=2.11 <2.14",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||||
"doctrine/orm": "^2.6",
|
"doctrine/orm": "^2.6",
|
||||||
|
"endroid/qr-code": "^6.1",
|
||||||
"pentatrion/vite-bundle": "^8.2",
|
"pentatrion/vite-bundle": "^8.2",
|
||||||
|
"scheb/2fa-backup-code": "^8.5",
|
||||||
|
"scheb/2fa-bundle": "^8.5",
|
||||||
|
"scheb/2fa-totp": "^8.5",
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/flex": "^2.10.0",
|
"symfony/flex": "^2.10.0",
|
||||||
"symfony/form": "7.4.*",
|
"symfony/form": "7.4.*",
|
||||||
|
|||||||
419
composer.lock
generated
419
composer.lock
generated
@@ -4,8 +4,63 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2d8ff385b04f98203cb1c117c260f6c4",
|
"content-hash": "ce98c65440896f6e3c709b7996e61033",
|
||||||
"packages": [
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "bacon/bacon-qr-code",
|
||||||
|
"version": "v3.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||||
|
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||||
|
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"dasprid/enum": "^1.0.3",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phly/keep-a-changelog": "^2.12",
|
||||||
|
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||||
|
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||||
|
"spatie/pixelmatch-php": "^1.2.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.9"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-imagick": "to generate QR code images"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"BaconQrCode\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-2-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Scholzen 'DASPRiD'",
|
||||||
|
"email": "mail@dasprids.de",
|
||||||
|
"homepage": "https://dasprids.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||||
|
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||||
|
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
|
||||||
|
},
|
||||||
|
"time": "2026-04-05T21:06:35+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
@@ -65,6 +120,56 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-17T12:54:54+00:00"
|
"time": "2026-03-17T12:54:54+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "dasprid/enum",
|
||||||
|
"version": "1.0.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/DASPRiD/Enum.git",
|
||||||
|
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||||
|
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.1 <9.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||||
|
"squizlabs/php_codesniffer": "*"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"DASPRiD\\Enum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-2-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Scholzen 'DASPRiD'",
|
||||||
|
"email": "mail@dasprids.de",
|
||||||
|
"homepage": "https://dasprids.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP 7.1 enum implementation",
|
||||||
|
"keywords": [
|
||||||
|
"enum",
|
||||||
|
"map"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||||
|
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||||
|
},
|
||||||
|
"time": "2025-09-16T12:23:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "doctrine/cache",
|
"name": "doctrine/cache",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
@@ -1452,6 +1557,78 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "endroid/qr-code",
|
||||||
|
"version": "6.1.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/endroid/qr-code.git",
|
||||||
|
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
|
||||||
|
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"bacon/bacon-qr-code": "^3.0",
|
||||||
|
"php": "^8.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"endroid/quality": "dev-main",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"khanamiryan/qrcode-detector-decoder": "^2.0.3",
|
||||||
|
"setasign/fpdf": "^1.8.2"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-gd": "Enables you to write PNG images",
|
||||||
|
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
|
||||||
|
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
|
||||||
|
"setasign/fpdf": "Enables you to use the PDF writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "6.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Endroid\\QrCode\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jeroen van den Enden",
|
||||||
|
"email": "info@endroid.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Endroid QR Code",
|
||||||
|
"homepage": "https://github.com/endroid/qr-code",
|
||||||
|
"keywords": [
|
||||||
|
"code",
|
||||||
|
"endroid",
|
||||||
|
"php",
|
||||||
|
"qr",
|
||||||
|
"qrcode"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/endroid/qr-code/issues",
|
||||||
|
"source": "https://github.com/endroid/qr-code/tree/6.1.3"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/endroid",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-02-05T07:01:58+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "lcobucci/jwt",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -2286,6 +2463,176 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "scheb/2fa-backup-code",
|
||||||
|
"version": "v8.5.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/scheb/2fa-backup-code.git",
|
||||||
|
"reference": "cf4251fcc24f4a39d1307d8bbfabecfbd21ed57b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/cf4251fcc24f4a39d1307d8bbfabecfbd21ed57b",
|
||||||
|
"reference": "cf4251fcc24f4a39d1307d8bbfabecfbd21ed57b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~8.4.0 || ~8.5.0",
|
||||||
|
"scheb/2fa-bundle": "self.version"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Scheb\\TwoFactorBundle\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christian Scheb",
|
||||||
|
"email": "me@christianscheb.de"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Extends scheb/2fa-bundle with backup codes support",
|
||||||
|
"homepage": "https://github.com/scheb/2fa",
|
||||||
|
"keywords": [
|
||||||
|
"2fa",
|
||||||
|
"Authentication",
|
||||||
|
"backup-codes",
|
||||||
|
"symfony",
|
||||||
|
"two-factor",
|
||||||
|
"two-step"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/scheb/2fa-backup-code/tree/v8.5.0"
|
||||||
|
},
|
||||||
|
"time": "2026-01-24T13:26:10+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheb/2fa-bundle",
|
||||||
|
"version": "v8.5.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/scheb/2fa-bundle.git",
|
||||||
|
"reference": "ae26ae91723685e3a8622f2f3b9119016de23e20"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/ae26ae91723685e3a8622f2f3b9119016de23e20",
|
||||||
|
"reference": "ae26ae91723685e3a8622f2f3b9119016de23e20",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "~8.4.0 || ~8.5.0",
|
||||||
|
"symfony/config": "^7.4 || ^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4 || ^8.0",
|
||||||
|
"symfony/event-dispatcher": "^7.4 || ^8.0",
|
||||||
|
"symfony/framework-bundle": "^7.4 || ^8.0",
|
||||||
|
"symfony/http-foundation": "^7.4 || ^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4 || ^8.0",
|
||||||
|
"symfony/property-access": "^7.4 || ^8.0",
|
||||||
|
"symfony/security-bundle": "^7.4 || ^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3",
|
||||||
|
"symfony/twig-bundle": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"scheb/two-factor-bundle": "*"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"scheb/2fa-backup-code": "Emergency codes when you have no access to other methods",
|
||||||
|
"scheb/2fa-email": "Send codes by email",
|
||||||
|
"scheb/2fa-google-authenticator": "Google Authenticator support",
|
||||||
|
"scheb/2fa-totp": "Temporary one-time password (TOTP) support (Google Authenticator compatible)",
|
||||||
|
"scheb/2fa-trusted-device": "Trusted devices support"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Scheb\\TwoFactorBundle\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christian Scheb",
|
||||||
|
"email": "me@christianscheb.de"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A generic interface to implement two-factor authentication in Symfony applications",
|
||||||
|
"homepage": "https://github.com/scheb/2fa",
|
||||||
|
"keywords": [
|
||||||
|
"2fa",
|
||||||
|
"Authentication",
|
||||||
|
"symfony",
|
||||||
|
"two-factor",
|
||||||
|
"two-step"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/scheb/2fa-bundle/tree/v8.5.0"
|
||||||
|
},
|
||||||
|
"time": "2026-03-24T18:33:45+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scheb/2fa-totp",
|
||||||
|
"version": "v8.5.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/scheb/2fa-totp.git",
|
||||||
|
"reference": "ca7562c6b6f9e5bb30cadcc98123327c2540e18f"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/scheb/2fa-totp/zipball/ca7562c6b6f9e5bb30cadcc98123327c2540e18f",
|
||||||
|
"reference": "ca7562c6b6f9e5bb30cadcc98123327c2540e18f",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~8.4.0 || ~8.5.0",
|
||||||
|
"scheb/2fa-bundle": "self.version",
|
||||||
|
"spomky-labs/otphp": "^11.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/validator": "Needed if you want to use the TOTP validator constraint"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Scheb\\TwoFactorBundle\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christian Scheb",
|
||||||
|
"email": "me@christianscheb.de"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Extends scheb/2fa-bundle with two-factor authentication using TOTP",
|
||||||
|
"homepage": "https://github.com/scheb/2fa",
|
||||||
|
"keywords": [
|
||||||
|
"2fa",
|
||||||
|
"Authentication",
|
||||||
|
"symfony",
|
||||||
|
"totp",
|
||||||
|
"two-factor",
|
||||||
|
"two-step"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/scheb/2fa-totp/tree/v8.5.0"
|
||||||
|
},
|
||||||
|
"time": "2026-01-24T13:27:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "spomky-labs/cbor-php",
|
"name": "spomky-labs/cbor-php",
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
@@ -2357,6 +2704,76 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-01T12:15:20+00:00"
|
"time": "2026-04-01T12:15:20+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spomky-labs/otphp",
|
||||||
|
"version": "11.4.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Spomky-Labs/otphp.git",
|
||||||
|
"reference": "2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad",
|
||||||
|
"reference": "2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"paragonie/constant_time_encoding": "^2.0 || ^3.0",
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/clock": "^1.0",
|
||||||
|
"symfony/deprecation-contracts": "^3.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/error-handler": "^6.4|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"OTPHP\\": "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/otphp/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator",
|
||||||
|
"homepage": "https://github.com/Spomky-Labs/otphp",
|
||||||
|
"keywords": [
|
||||||
|
"FreeOTP",
|
||||||
|
"RFC 4226",
|
||||||
|
"RFC 6238",
|
||||||
|
"google authenticator",
|
||||||
|
"hotp",
|
||||||
|
"otp",
|
||||||
|
"totp"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Spomky-Labs/otphp/issues",
|
||||||
|
"source": "https://github.com/Spomky-Labs/otphp/tree/11.4.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Spomky",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/FlorentMorselli",
|
||||||
|
"type": "patreon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-23T10:53:01+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "spomky-labs/pki-framework",
|
"name": "spomky-labs/pki-framework",
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ return [
|
|||||||
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
|
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
|
||||||
Webauthn\Bundle\WebauthnBundle::class => ['all' => true],
|
Webauthn\Bundle\WebauthnBundle::class => ['all' => true],
|
||||||
Webauthn\Stimulus\WebauthnStimulusBundle::class => ['all' => true],
|
Webauthn\Stimulus\WebauthnStimulusBundle::class => ['all' => true],
|
||||||
|
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
13
config/packages/scheb_2fa.yaml
Normal file
13
config/packages/scheb_2fa.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
scheb_two_factor:
|
||||||
|
security_tokens:
|
||||||
|
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
|
||||||
|
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
|
||||||
|
|
||||||
|
totp:
|
||||||
|
enabled: true
|
||||||
|
issuer: "Mine Seeker"
|
||||||
|
server_name: "Mine Seeker"
|
||||||
|
template: "Security/2fa.html.twig"
|
||||||
|
|
||||||
|
backup_codes:
|
||||||
|
enabled: true
|
||||||
@@ -17,6 +17,14 @@ security:
|
|||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
user_checker: App\Security\UserChecker
|
user_checker: App\Security\UserChecker
|
||||||
|
two_factor:
|
||||||
|
check_path: 2fa_login_check
|
||||||
|
auth_form_path: 2fa_login
|
||||||
|
auth_code_parameter_name: _auth_code
|
||||||
|
post_only: true
|
||||||
|
default_target_path: MineSeekerBundle_homepage
|
||||||
|
prepare_on_login: true
|
||||||
|
prepare_on_access_denied: true
|
||||||
form_login:
|
form_login:
|
||||||
login_path: MineSeekerBundle_login
|
login_path: MineSeekerBundle_login
|
||||||
check_path: MineSeekerBundle_login
|
check_path: MineSeekerBundle_login
|
||||||
@@ -27,11 +35,9 @@ security:
|
|||||||
logout:
|
logout:
|
||||||
path: MineSeekerBundle_logout
|
path: MineSeekerBundle_logout
|
||||||
target: MineSeekerBundle_homepage
|
target: MineSeekerBundle_homepage
|
||||||
remember_me:
|
switch_user: false
|
||||||
secret: '%kernel.secret%'
|
|
||||||
lifetime: 604800
|
|
||||||
remember_me_parameter: _remember_me
|
|
||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
|
- { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
|
||||||
- { path: ^/api/webauthn/authentication/begin, roles: PUBLIC_ACCESS }
|
- { path: ^/api/webauthn/authentication/begin, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/webauthn/authentication/complete, roles: PUBLIC_ACCESS }
|
- { path: ^/api/webauthn/authentication/complete, roles: PUBLIC_ACCESS }
|
||||||
|
|||||||
11
config/routes/scheb_2fa.yaml
Normal file
11
config/routes/scheb_2fa.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
2fa_login:
|
||||||
|
path: /2fa
|
||||||
|
defaults:
|
||||||
|
_controller: "scheb_two_factor.form_controller::form"
|
||||||
|
requirements:
|
||||||
|
_method: GET
|
||||||
|
|
||||||
|
2fa_login_check:
|
||||||
|
path: /2fa_check
|
||||||
|
requirements:
|
||||||
|
_method: POST
|
||||||
@@ -34,7 +34,9 @@ class ProfileController extends AbstractController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PlayedGameRepository $repo,
|
private readonly PlayedGameRepository $repo,
|
||||||
private readonly WebAuthnService $webAuthnService
|
private readonly WebAuthnService $webAuthnService
|
||||||
) { }
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
@@ -73,6 +75,8 @@ class ProfileController extends AbstractController
|
|||||||
|
|
||||||
return $this->render('Security/profile_security.html.twig', [
|
return $this->render('Security/profile_security.html.twig', [
|
||||||
'credentials' => $credentialsData,
|
'credentials' => $credentialsData,
|
||||||
|
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
|
||||||
|
'backupCodesCount' => \count($user->getBackupCodes()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ class SecurityController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
|
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
|
||||||
public function logout(): void
|
public function logout(): never
|
||||||
{
|
{
|
||||||
// Intercepted by the security firewall — never executed.
|
throw new \LogicException('This action is intercepted by the security firewall.');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/register', name: 'MineSeekerBundle_register')]
|
#[Route('/register', name: 'MineSeekerBundle_register')]
|
||||||
|
|||||||
236
src/Controller/TwoFactorController.php
Normal file
236
src/Controller/TwoFactorController.php
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Endroid\QrCode\Builder\Builder;
|
||||||
|
use Endroid\QrCode\Exception\ValidationException;
|
||||||
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
|
use RuntimeException;
|
||||||
|
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class TwoFactorController
|
||||||
|
*
|
||||||
|
* Handles TOTP 2FA setup/teardown and the login challenge form.
|
||||||
|
*
|
||||||
|
* @package App\Controller
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @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]
|
||||||
|
class TwoFactorController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TotpAuthenticatorInterface $totpAuthenticator,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2FA challenge form shown during login when TOTP is enabled. */
|
||||||
|
#[Route('/2fa', name: '2fa_login', methods: ['GET'])]
|
||||||
|
public function loginForm(): Response
|
||||||
|
{
|
||||||
|
return $this->render('Security/2fa.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy action — the actual POST is intercepted by the scheb/2fa firewall listener
|
||||||
|
* before it ever reaches a controller. The route must exist so path('2fa_login_check')
|
||||||
|
* resolves in Twig.
|
||||||
|
*/
|
||||||
|
#[Route('/2fa_check', name: '2fa_login_check', methods: ['POST'])]
|
||||||
|
public function loginCheck(): never
|
||||||
|
{
|
||||||
|
throw new \LogicException('This action is intercepted by the 2FA firewall listener.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 1 — generate a pending secret, store it in the session. */
|
||||||
|
#[Route('/profile/security/2fa/prepare', name: 'MineSeekerBundle_2fa_prepare', methods: ['POST'])]
|
||||||
|
public function prepare(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('2fa_prepare', $request->request->get('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = $this->totpAuthenticator->generateSecret();
|
||||||
|
$request->getSession()->set('totp_pending_secret', $secret);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_2fa_setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2 — show the setup page with QR code and verification form. */
|
||||||
|
#[Route('/profile/security/2fa/setup', name: 'MineSeekerBundle_2fa_setup', methods: ['GET'])]
|
||||||
|
public function setup(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
$pendingSecret = $request->getSession()->get('totp_pending_secret');
|
||||||
|
if (!$pendingSecret) {
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('Security/2fa_setup.html.twig', [
|
||||||
|
'pending_secret' => $pendingSecret,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve a PNG QR code for the pending TOTP secret. */
|
||||||
|
#[Route('/profile/security/2fa/qr-code', name: 'MineSeekerBundle_2fa_qr_code', methods: ['GET'])]
|
||||||
|
public function qrCode(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
$pendingSecret = $request->getSession()->get('totp_pending_secret');
|
||||||
|
|
||||||
|
if (!$pendingSecret) {
|
||||||
|
return new Response('', Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$provisioningUri = sprintf(
|
||||||
|
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
|
||||||
|
rawurlencode('Mine Seeker'),
|
||||||
|
rawurlencode($user->getUserIdentifier()),
|
||||||
|
$pendingSecret,
|
||||||
|
rawurlencode('Mine Seeker'),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = new Builder(
|
||||||
|
writer: new PngWriter(),
|
||||||
|
data: $provisioningUri,
|
||||||
|
size: 220,
|
||||||
|
margin: 10,
|
||||||
|
)->build();
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw new RuntimeException("Failed to generate QR code: $e->getMessage()", 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
$result->getString(),
|
||||||
|
Response::HTTP_OK,
|
||||||
|
['Content-Type' => 'image/png', 'Cache-Control' => 'no-cache, no-store, must-revalidate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 3 — verify the first code and save the secret. */
|
||||||
|
#[Route('/profile/security/2fa/enable', name: 'MineSeekerBundle_2fa_enable', methods: ['POST'])]
|
||||||
|
public function enable(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('2fa_enable', $request->request->get('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
$pendingSecret = $request->getSession()->get('totp_pending_secret');
|
||||||
|
|
||||||
|
if (!$pendingSecret) {
|
||||||
|
$this->addFlash('error', 'No pending 2FA setup found. Please start again.');
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $request->request->getString('_auth_code');
|
||||||
|
|
||||||
|
// Temporarily set the pending secret to verify the code
|
||||||
|
$user->setTotpSecret($pendingSecret);
|
||||||
|
|
||||||
|
if (!$this->totpAuthenticator->checkCode($user, $code)) {
|
||||||
|
$user->setTotpSecret(null);
|
||||||
|
$this->addFlash('error', 'Invalid verification code. Please try again.');
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_2fa_setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupCodes = $this->generateBackupCodes();
|
||||||
|
$user->setBackupCodes($backupCodes);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$request->getSession()->remove('totp_pending_secret');
|
||||||
|
$this->addFlash('2fa_backup_codes', $backupCodes);
|
||||||
|
$this->addFlash('success', 'Two-factor authentication has been enabled.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disable TOTP for the current user. */
|
||||||
|
#[Route('/profile/security/2fa/disable', name: 'MineSeekerBundle_2fa_disable', methods: ['POST'])]
|
||||||
|
public function disable(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('2fa_disable', $request->request->get('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
$user->setTotpSecret(null);
|
||||||
|
$user->setBackupCodes([]);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Two-factor authentication has been disabled.');
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regenerate backup codes for the current user. */
|
||||||
|
#[Route('/profile/security/2fa/backup-codes/regenerate', name: 'MineSeekerBundle_2fa_backup_regenerate', methods: ['POST'])]
|
||||||
|
public function regenerateBackupCodes(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('2fa_backup_regen', $request->request->get('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
if (!$user->isTotpAuthenticationEnabled()) {
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupCodes = $this->generateBackupCodes();
|
||||||
|
$user->setBackupCodes($backupCodes);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('2fa_backup_codes', $backupCodes);
|
||||||
|
$this->addFlash('success', 'Backup codes have been regenerated. Save them somewhere safe.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] Eight 8-character uppercase alphanumeric codes */
|
||||||
|
private function generateBackupCodes(int $count = 8): array
|
||||||
|
{
|
||||||
|
$codes = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$codes[] = strtoupper(bin2hex(random_bytes(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Security\PasskeyToken;
|
||||||
use App\Service\WebAuthnService;
|
use App\Service\WebAuthnService;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -19,7 +20,6 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
|
||||||
use Webauthn\AuthenticatorSelectionCriteria;
|
use Webauthn\AuthenticatorSelectionCriteria;
|
||||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
use Webauthn\PublicKeyCredentialParameters;
|
use Webauthn\PublicKeyCredentialParameters;
|
||||||
@@ -295,7 +295,7 @@ class WebAuthnController extends AbstractController
|
|||||||
$this->webAuthnService->updateLastUsedAt($credentialId, $user);
|
$this->webAuthnService->updateLastUsedAt($credentialId, $user);
|
||||||
|
|
||||||
/** Log in the user using token storage */
|
/** Log in the user using token storage */
|
||||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
$token = new PasskeyToken($user, 'main', $user->getRoles());
|
||||||
$this->tokenStorage->setToken($token);
|
$this->tokenStorage->setToken($token);
|
||||||
$request->getSession()->set('_security_main', serialize($token));
|
$request->getSession()->set('_security_main', serialize($token));
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ use Doctrine\ORM\Mapping\Entity;
|
|||||||
use Doctrine\ORM\Mapping\GeneratedValue;
|
use Doctrine\ORM\Mapping\GeneratedValue;
|
||||||
use Doctrine\ORM\Mapping\Id;
|
use Doctrine\ORM\Mapping\Id;
|
||||||
use Doctrine\ORM\Mapping\Table;
|
use Doctrine\ORM\Mapping\Table;
|
||||||
|
use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
|
||||||
|
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
|
||||||
|
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
|
||||||
|
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface as TotpTwoFactorInterface;
|
||||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
@@ -36,7 +40,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
|||||||
#[Entity(repositoryClass: UserRepository::class)]
|
#[Entity(repositoryClass: UserRepository::class)]
|
||||||
#[UniqueEntity(fields: ['username'], message: 'This username is already taken.')]
|
#[UniqueEntity(fields: ['username'], message: 'This username is already taken.')]
|
||||||
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
|
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwoFactorInterface, BackupCodeInterface
|
||||||
{
|
{
|
||||||
#[Id, GeneratedValue, Column]
|
#[Id, GeneratedValue, Column]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
@@ -65,6 +69,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
private ?DateTime $resetTokenExpiresAt = null;
|
private ?DateTime $resetTokenExpiresAt = null;
|
||||||
|
|
||||||
|
#[Column(length: 255, nullable: true)]
|
||||||
|
private ?string $totpSecret = null;
|
||||||
|
|
||||||
|
#[Column(type: Types::JSON, nullable: true)]
|
||||||
|
private ?array $backupCodes = [];
|
||||||
|
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
@@ -169,4 +179,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
|
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TotpTwoFactorInterface ---
|
||||||
|
|
||||||
|
public function isTotpAuthenticationEnabled(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->totpSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotpAuthenticationUsername(): string
|
||||||
|
{
|
||||||
|
return $this->getUserIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
|
||||||
|
{
|
||||||
|
if (null === $this->totpSecret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotpSecret(): ?string
|
||||||
|
{
|
||||||
|
return $this->totpSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTotpSecret(?string $totpSecret): self
|
||||||
|
{
|
||||||
|
$this->totpSecret = $totpSecret;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BackupCodeInterface ---
|
||||||
|
|
||||||
|
public function isBackupCode(string $code): bool
|
||||||
|
{
|
||||||
|
return \in_array($code, $this->backupCodes ?? [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateBackupCode(string $code): void
|
||||||
|
{
|
||||||
|
$this->backupCodes = array_values(array_filter($this->backupCodes ?? [], fn($c) => $c !== $code));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBackupCodes(): array
|
||||||
|
{
|
||||||
|
return $this->backupCodes ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBackupCodes(array $backupCodes): self
|
||||||
|
{
|
||||||
|
$this->backupCodes = $backupCodes;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ readonly class LoginCaptchaListener
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private RecaptchaService $recaptcha,
|
private RecaptchaService $recaptcha,
|
||||||
private RequestStack $requestStack,
|
private RequestStack $requestStack,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function __invoke(CheckPassportEvent $event): void
|
public function __invoke(CheckPassportEvent $event): void
|
||||||
{
|
{
|
||||||
@@ -46,11 +47,18 @@ readonly class LoginCaptchaListener
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $request->request->getString('g-recaptcha-response');
|
$path = $request->getPathInfo();
|
||||||
$remoteIp = (string) $request->getClientIp();
|
|
||||||
|
|
||||||
if (!$this->recaptcha->verify($token, $remoteIp)) {
|
if ($path === '/2fa_check' || strpos($path, '/2fa') === 0) {
|
||||||
throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$token = $request->request->getString('g-recaptcha-response');
|
||||||
|
|
||||||
|
if ($this->recaptcha->verify($token, $request->getClientIp())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
44
src/Migrations/2026/04/Version20260412090000.php
Normal file
44
src/Migrations/2026/04/Version20260412090000.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Migrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Version20260412090000
|
||||||
|
*
|
||||||
|
* @package App\Migrations
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @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 Version20260412090000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add TOTP 2FA and backup codes to User';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE app_user ADD totp_secret VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE app_user ADD backup_codes JSON DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE app_user DROP totp_secret');
|
||||||
|
$this->addSql('ALTER TABLE app_user DROP backup_codes');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Security/PasskeyToken.php
Normal file
54
src/Security/PasskeyToken.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token used for passkey (WebAuthn) authentication.
|
||||||
|
* Intentionally not listed in scheb/2fa security_tokens so passkey logins
|
||||||
|
* bypass the TOTP 2FA challenge — passkeys are already a strong second factor.
|
||||||
|
*
|
||||||
|
* @package App\Security
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @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 PasskeyToken extends AbstractToken
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
UserInterface $user,
|
||||||
|
private readonly string $firewallName,
|
||||||
|
array $roles = [],
|
||||||
|
) {
|
||||||
|
parent::__construct($roles);
|
||||||
|
$this->setUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFirewallName(): string
|
||||||
|
{
|
||||||
|
return $this->firewallName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __serialize(): array
|
||||||
|
{
|
||||||
|
return [$this->firewallName, parent::__serialize()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __unserialize(array $data): void
|
||||||
|
{
|
||||||
|
[$this->firewallName, $parentData] = $data;
|
||||||
|
parent::__unserialize($parentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
symfony.lock
13
symfony.lock
@@ -122,6 +122,19 @@
|
|||||||
"roave/security-advisories": {
|
"roave/security-advisories": {
|
||||||
"version": "dev-master"
|
"version": "dev-master"
|
||||||
},
|
},
|
||||||
|
"scheb/2fa-bundle": {
|
||||||
|
"version": "8.5",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.0",
|
||||||
|
"ref": "1e6f68089146853a790b5da9946fc5974f6fcd49"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/scheb_2fa.yaml",
|
||||||
|
"config/routes/scheb_2fa.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"sonata-project/core-bundle": {
|
"sonata-project/core-bundle": {
|
||||||
"version": "3.9",
|
"version": "3.9",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
54
templates/Security/2fa.html.twig
Normal file
54
templates/Security/2fa.html.twig
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends 'Game/index.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %} - Two-Factor Authentication{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2 class="auth-title">Two-Factor Authentication</h2>
|
||||||
|
<p class="auth-sub">Enter the 6-digit code from your authenticator app</p>
|
||||||
|
|
||||||
|
{% if authenticationError is defined and authenticationError %}
|
||||||
|
<div class="auth-error">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
|
{{ authenticationError|trans({}, 'SchebTwoFactorBundle') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="auth-form" method="post" action="{{ path('2fa_login_check') }}">
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="auth-code" class="auth-label">Authentication Code</label>
|
||||||
|
<div class="auth-input-wrap">
|
||||||
|
<i class="fa fa-shield auth-input-icon"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="auth-code"
|
||||||
|
name="_auth_code"
|
||||||
|
class="auth-input auth-input--code"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxlength="8"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="auth-field-hint">Or enter one of your backup codes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="auth-submit">
|
||||||
|
<i class="fa fa-check"></i> Verify
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ path('MineSeekerBundle_logout') }}" class="auth-cancel-standalone">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"/>
|
||||||
|
<button type="submit" class="auth-cancel auth-cancel--block">
|
||||||
|
<i class="fa fa-sign-out"></i> Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
63
templates/Security/2fa_setup.html.twig
Normal file
63
templates/Security/2fa_setup.html.twig
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{% extends 'Game/index.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %} - Enable Two-Factor Authentication{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card auth-card--wide">
|
||||||
|
<h2 class="auth-title">Enable Two-Factor Authentication</h2>
|
||||||
|
<p class="auth-sub">Scan the QR code with your authenticator app</p>
|
||||||
|
|
||||||
|
<div class="twofa-setup">
|
||||||
|
<div class="twofa-setup__qr">
|
||||||
|
<img
|
||||||
|
src="{{ path('MineSeekerBundle_2fa_qr_code') }}"
|
||||||
|
alt="TOTP QR Code"
|
||||||
|
width="220"
|
||||||
|
height="220"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="twofa-setup__manual">
|
||||||
|
<p class="twofa-setup__manual-label">Can't scan? Enter this key manually:</p>
|
||||||
|
<code class="twofa-setup__secret">{{ pending_secret }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_2fa_enable') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('2fa_enable') }}"/>
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="auth-code" class="auth-label">Verification Code</label>
|
||||||
|
<div class="auth-input-wrap">
|
||||||
|
<i class="fa fa-shield auth-input-icon"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="auth-code"
|
||||||
|
name="_auth_code"
|
||||||
|
class="auth-input auth-input--code"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="auth-field-hint">Enter the 6-digit code from your app to confirm setup.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="auth-submit">
|
||||||
|
<i class="fa fa-check"></i> Activate 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-back">
|
||||||
|
<a href="{{ path('MineSeekerBundle_profile_security') }}">
|
||||||
|
<i class="fa fa-chevron-left"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -45,12 +45,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="profile-actions">
|
|
||||||
<a href="{{ path('MineSeekerBundle_profile_security') }}" class="profile-action-btn">
|
|
||||||
<i class="fa fa-lock"></i> Security Settings
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if recent|length > 0 %}
|
{% if recent|length > 0 %}
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h2 class="profile-section__title">
|
<h2 class="profile-section__title">
|
||||||
|
|||||||
@@ -4,18 +4,6 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="profile-page">
|
<div class="profile-page">
|
||||||
<div class="profile-header">
|
|
||||||
<div class="profile-avatar">
|
|
||||||
{{ app.user.username|slice(0, 2)|upper }}
|
|
||||||
</div>
|
|
||||||
<div class="profile-info">
|
|
||||||
<h1 class="profile-name">{{ app.user.username }}</h1>
|
|
||||||
<p class="profile-role">
|
|
||||||
<i class="fa fa-lock"></i> Security Settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="profile-actions">
|
<div class="profile-actions">
|
||||||
<a href="{{ path('MineSeekerBundle_profile') }}" class="profile-action-btn">
|
<a href="{{ path('MineSeekerBundle_profile') }}" class="profile-action-btn">
|
||||||
<i class="fa fa-chevron-left"></i> Back to Profile
|
<i class="fa fa-chevron-left"></i> Back to Profile
|
||||||
@@ -39,6 +27,83 @@
|
|||||||
}|json_encode|e('html') }}"
|
}|json_encode|e('html') }}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section">
|
||||||
|
<h2 class="profile-section__title">
|
||||||
|
<i class="fa fa-shield"></i> Two-Factor Authentication
|
||||||
|
</h2>
|
||||||
|
<p class="profile-section__description">
|
||||||
|
Add an extra layer of security by requiring a one-time code from your authenticator app each time you sign in
|
||||||
|
with a password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% set newBackupCodes = app.flashes('2fa_backup_codes') %}
|
||||||
|
|
||||||
|
{% if isTotpEnabled %}
|
||||||
|
<div class="twofa-status twofa-status--enabled">
|
||||||
|
<i class="fa fa-check-circle"></i>
|
||||||
|
Two-factor authentication is <strong>active</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if newBackupCodes|length > 0 %}
|
||||||
|
<div class="twofa-backup-reveal">
|
||||||
|
<p class="twofa-backup-reveal__warning">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
|
Save these backup codes now — they will not be shown again.
|
||||||
|
</p>
|
||||||
|
<div class="twofa-backup-reveal__grid">
|
||||||
|
{% for code in newBackupCodes[0] %}
|
||||||
|
<code class="twofa-backup-code">{{ code }}</code>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="twofa-actions">
|
||||||
|
<form method="post" action="{{ path('MineSeekerBundle_2fa_disable') }}" class="twofa-actions__form">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('2fa_disable') }}"/>
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">
|
||||||
|
<i class="fa fa-times"></i> Disable 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="twofa-backup-meta">
|
||||||
|
<span class="twofa-backup-meta__count">
|
||||||
|
<i class="fa fa-life-ring"></i>
|
||||||
|
{{ backupCodesCount }} backup code{{ backupCodesCount != 1 ? 's' : '' }} remaining
|
||||||
|
</span>
|
||||||
|
<form method="post" action="{{ path('MineSeekerBundle_2fa_backup_regenerate') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('2fa_backup_regen') }}"/>
|
||||||
|
<button type="submit" class="btn btn--secondary btn--sm">
|
||||||
|
<i class="fa fa-refresh"></i> Regenerate codes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="twofa-status twofa-status--disabled">
|
||||||
|
<i class="fa fa-times-circle"></i>
|
||||||
|
Two-factor authentication is <strong>not enabled</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ path('MineSeekerBundle_2fa_prepare') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('2fa_prepare') }}"/>
|
||||||
|
<button type="submit" class="btn btn--primary">
|
||||||
|
<i class="fa fa-shield"></i> Enable 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section">
|
||||||
|
<h2 class="profile-section__title">
|
||||||
|
<i class="fa fa-key"></i> Password changing
|
||||||
|
</h2>
|
||||||
|
<p class="profile-section__description">
|
||||||
|
The password changing only possible through the Forgot password functionality on the Login page.
|
||||||
|
The system would log you out anyway after requesting a new password, so this feature wasn't implemented here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user