Private
Public Access
1
0

new: usr: implement the 2FA authentication (TOTP and backup codes) #4

This commit is contained in:
2026-04-12 17:55:57 +02:00
parent 0144a3953c
commit fb8a54f687
23 changed files with 1603 additions and 266 deletions

View File

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

View File

@@ -1,304 +1,362 @@
.profile-page { .profile-page {
max-width: 760px; max-width: 760px;
margin: 0 auto; margin: 0 auto;
padding: 48px 24px 80px; padding: 48px 24px 80px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 32px;
} }
.profile-header { .profile-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 28px; gap: 28px;
padding: 32px 36px; padding: 32px 36px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(35, 111, 135, 0.2); border: 1px solid rgba(35, 111, 135, 0.2);
border-radius: 10px; border-radius: 10px;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
} }
.profile-avatar { .profile-avatar {
width: 80px; width: 80px;
height: 80px; height: 80px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.45) 0%, rgba(173, 10, 5, 0.3) 100%); background: linear-gradient(135deg, rgba(35, 111, 135, 0.45) 0%, rgba(173, 10, 5, 0.3) 100%);
border: 2px solid rgba(35, 111, 135, 0.45); border: 2px solid rgba(35, 111, 135, 0.45);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
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);
} }
.profile-info { .profile-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
.profile-name { .profile-name {
font: 800 32px 'Rajdhani', sans-serif; font: 800 32px 'Rajdhani', sans-serif;
color: #ffffff; color: #ffffff;
letter-spacing: 1px; letter-spacing: 1px;
line-height: 1; line-height: 1;
} }
.profile-email { .profile-email {
font: 400 14px 'Rajdhani', sans-serif; font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6); color: rgba(149, 207, 245, 0.6);
letter-spacing: 0.5px; letter-spacing: 0.5px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
overflow: hidden; overflow: hidden;
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 {
font: 600 11px 'Rajdhani', sans-serif; font: 600 11px 'Rajdhani', sans-serif;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 2.5px; letter-spacing: 2.5px;
color: rgba(255, 255, 255, 0.25); color: rgba(255, 255, 255, 0.25);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
i { font-size: 10px; } i {
font-size: 10px;
}
} }
.profile-stats { .profile-stats {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 14px; gap: 14px;
} }
.profile-stat { .profile-stat {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px; border-radius: 8px;
padding: 24px 16px 20px; padding: 24px 16px 20px;
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
transition: border-color 200ms ease, background 200ms ease; transition: border-color 200ms ease, background 200ms ease;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(35, 111, 135, 0.3);
}
&--win {
border-color: rgba(42, 158, 96, 0.18);
&:hover { &:hover {
background: rgba(255, 255, 255, 0.05); border-color: rgba(42, 158, 96, 0.45);
border-color: rgba(35, 111, 135, 0.3);
} }
}
&--win { &--loss {
border-color: rgba(42, 158, 96, 0.18); border-color: rgba(173, 10, 5, 0.18);
&:hover { border-color: rgba(42, 158, 96, 0.45); }
}
&--loss { &:hover {
border-color: rgba(173, 10, 5, 0.18); 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);
} }
}
} }
.profile-stat__icon { .profile-stat__icon {
font-size: 18px; font-size: 18px;
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 {
display: block; display: block;
font: 800 40px 'Rajdhani', sans-serif; font: 800 40px 'Rajdhani', sans-serif;
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 {
font: 600 10px 'Rajdhani', sans-serif; font: 600 10px 'Rajdhani', sans-serif;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 2px; letter-spacing: 2px;
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.3);
} }
.profile-actions { .profile-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.profile-action-btn { .profile-action-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 9px 18px; padding: 9px 18px;
background: rgba(35, 111, 135, 0.12); background: rgba(35, 111, 135, 0.12);
color: rgba(149, 207, 245, 0.75); color: rgba(149, 207, 245, 0.75);
border: 1px solid rgba(35, 111, 135, 0.3); border: 1px solid rgba(35, 111, 135, 0.3);
border-radius: 6px; 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; 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 { .profile-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
margin-bottom: 32px;
} }
.profile-section__description { .profile-section__description {
font-size: 13px; font-size: 13px;
color: rgba(149, 207, 245, 0.65); color: rgba(149, 207, 245, 0.65);
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
.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);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
i { opacity: 0.7; } i {
opacity: 0.7;
}
} }
.profile-games { .profile-games {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
.profile-game { .profile-game {
display: grid; display: grid;
grid-template-columns: 26px 76px 22px 1fr 18px auto; grid-template-columns: 26px 76px 22px 1fr 18px auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 11px 16px; padding: 11px 16px;
border-radius: 6px; border-radius: 6px;
background: rgba(255, 255, 255, 0.025); background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.055); border: 1px solid rgba(255, 255, 255, 0.055);
border-left-width: 3px; border-left-width: 3px;
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 {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
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 {
font: 700 14px 'Rajdhani', sans-serif; font: 700 14px 'Rajdhani', sans-serif;
color: #fff; color: #fff;
letter-spacing: 1px; letter-spacing: 1px;
} }
.profile-game__vs { .profile-game__vs {
font: 400 10px 'Rajdhani', sans-serif; font: 400 10px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.22); color: rgba(255, 255, 255, 0.22);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
text-align: center; text-align: center;
} }
.profile-game__opponent { .profile-game__opponent {
color: rgba(149, 207, 245, 0.7); color: rgba(149, 207, 245, 0.7);
letter-spacing: 0.5px; letter-spacing: 0.5px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.profile-game__color { .profile-game__color {
font-size: 10px; font-size: 10px;
opacity: 0.6; opacity: 0.6;
} }
.profile-game__date { .profile-game__date {
font: 400 11px 'Rajdhani', sans-serif; font: 400 11px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.25); color: rgba(255, 255, 255, 0.25);
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
.profile-empty { .profile-empty {
text-align: center; text-align: center;
padding: 48px 20px; padding: 48px 20px;
color: rgba(255, 255, 255, 0.25); color: rgba(255, 255, 255, 0.25);
i { i {
font-size: 40px; font-size: 40px;
display: block; display: block;
margin-bottom: 16px; margin-bottom: 16px;
opacity: 0.4; opacity: 0.4;
} }
p { p {
font: 400 15px 'Rajdhani', sans-serif; font: 400 15px 'Rajdhani', sans-serif;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
a { a {
color: #95cff5; color: #95cff5;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
transition: color 180ms; transition: color 180ms;
&:hover { color: #c5e8ff; } &:hover {
color: #c5e8ff;
} }
}
} }

View File

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

View File

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

View File

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

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

View File

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

View 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

View File

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

View 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

View File

@@ -33,8 +33,10 @@ 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
@@ -62,17 +64,19 @@ class ProfileController extends AbstractController
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$credentials = $this->webAuthnService->getCredentialsForUser($user); $credentials = $this->webAuthnService->getCredentialsForUser($user);
$credentialsData = array_map(fn ($cred) => [ $credentialsData = array_map(fn($cred) => [
'id' => $cred->getId(), 'id' => $cred->getId(),
'credentialName' => $cred->getCredentialName(), 'credentialName' => $cred->getCredentialName(),
'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'), 'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'),
'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'), 'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'),
'isBackupEligible' => $cred->isBackupEligible(), 'isBackupEligible' => $cred->isBackupEligible(),
'isBackupAuthenticated' => $cred->isBackupAuthenticated(), 'isBackupAuthenticated' => $cred->isBackupAuthenticated(),
], $credentials); ], $credentials);
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()),
]); ]);
} }
} }

View File

@@ -55,17 +55,17 @@ 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')]
public function register( public function register(
Request $request, Request $request,
UserPasswordHasherInterface $hasher, UserPasswordHasherInterface $hasher,
EntityManagerInterface $em, EntityManagerInterface $em,
MailerInterface $mailer, MailerInterface $mailer,
): Response { ): Response {
if ($this->getUser()) { if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage'); return $this->redirectToRoute('MineSeekerBundle_homepage');
@@ -114,10 +114,10 @@ class SecurityController extends AbstractController
#[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')] #[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')]
public function forgotPassword( public function forgotPassword(
Request $request, Request $request,
UserRepository $userRepository, UserRepository $userRepository,
EntityManagerInterface $em, EntityManagerInterface $em,
MailerInterface $mailer, MailerInterface $mailer,
): Response { ): Response {
if ($this->getUser()) { if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage'); return $this->redirectToRoute('MineSeekerBundle_homepage');
@@ -128,7 +128,7 @@ class SecurityController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData(); $email = $form->get('email')->getData();
$user = $userRepository->findOneByEmail($email); $user = $userRepository->findOneByEmail($email);
if ($user && $user->isVerified()) { if ($user && $user->isVerified()) {
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
@@ -167,10 +167,10 @@ class SecurityController extends AbstractController
#[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')] #[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')]
public function resetPassword( public function resetPassword(
string $token, string $token,
Request $request, Request $request,
UserRepository $userRepository, UserRepository $userRepository,
EntityManagerInterface $em, EntityManagerInterface $em,
UserPasswordHasherInterface $hasher, UserPasswordHasherInterface $hasher,
): Response { ): Response {
$user = $userRepository->findOneByResetToken($token); $user = $userRepository->findOneByResetToken($token);

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

View File

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

View File

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

View File

@@ -35,8 +35,9 @@ 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.');
} }
} }

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

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

View File

@@ -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": {

View 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 %}

View 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 %}

View File

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

View File

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