diff --git a/assets/css/homepage/_auth.scss b/assets/css/homepage/_auth.scss index c4eb643..12c33af 100644 --- a/assets/css/homepage/_auth.scss +++ b/assets/css/homepage/_auth.scss @@ -207,6 +207,74 @@ &: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 { font: 400 13px 'Rajdhani', sans-serif; color: rgba(255, 255, 255, 0.35); @@ -285,3 +353,78 @@ .auth-passkey-btn:active { 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; + } +} diff --git a/assets/css/homepage/_profile.scss b/assets/css/homepage/_profile.scss index e7a6167..1cedb09 100644 --- a/assets/css/homepage/_profile.scss +++ b/assets/css/homepage/_profile.scss @@ -1,304 +1,362 @@ .profile-page { - max-width: 760px; - margin: 0 auto; - padding: 48px 24px 80px; - display: flex; - flex-direction: column; - gap: 32px; + max-width: 760px; + margin: 0 auto; + padding: 48px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; } .profile-header { - display: flex; - align-items: center; - gap: 28px; - padding: 32px 36px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(35, 111, 135, 0.2); - border-radius: 10px; - backdrop-filter: blur(4px); - box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + gap: 28px; + padding: 32px 36px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(35, 111, 135, 0.2); + border-radius: 10px; + backdrop-filter: blur(4px); + box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); } .profile-avatar { - width: 80px; - height: 80px; - flex-shrink: 0; - border-radius: 50%; - 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); - display: flex; - align-items: center; - justify-content: center; - font: 800 28px 'Rajdhani', sans-serif; - color: rgba(149, 207, 245, 0.9); - letter-spacing: 2px; - box-shadow: - 0 0 0 4px rgba(35, 111, 135, 0.08), - 0 0 24px rgba(35, 111, 135, 0.2); + width: 80px; + height: 80px; + flex-shrink: 0; + border-radius: 50%; + 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); + display: flex; + align-items: center; + justify-content: center; + font: 800 28px 'Rajdhani', sans-serif; + color: rgba(149, 207, 245, 0.9); + letter-spacing: 2px; + box-shadow: 0 0 0 4px rgba(35, 111, 135, 0.08), + 0 0 24px rgba(35, 111, 135, 0.2); } .profile-info { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; } .profile-name { - font: 800 32px 'Rajdhani', sans-serif; - color: #ffffff; - letter-spacing: 1px; - line-height: 1; + font: 800 32px 'Rajdhani', sans-serif; + color: #ffffff; + letter-spacing: 1px; + line-height: 1; } .profile-email { - font: 400 14px 'Rajdhani', sans-serif; - color: rgba(149, 207, 245, 0.6); - letter-spacing: 0.5px; - display: flex; - align-items: center; - gap: 7px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font: 400 14px 'Rajdhani', sans-serif; + color: rgba(149, 207, 245, 0.6); + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; - i { font-size: 11px; opacity: 0.7; } + i { + font-size: 11px; + opacity: 0.7; + } } .profile-role { - font: 600 11px 'Rajdhani', sans-serif; - text-transform: uppercase; - letter-spacing: 2.5px; - color: rgba(255, 255, 255, 0.25); - display: flex; - align-items: center; - gap: 6px; + font: 600 11px 'Rajdhani', sans-serif; + text-transform: uppercase; + letter-spacing: 2.5px; + color: rgba(255, 255, 255, 0.25); + display: flex; + align-items: center; + gap: 6px; - i { font-size: 10px; } + i { + font-size: 10px; + } } .profile-stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 14px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; } .profile-stat { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 8px; - padding: 24px 16px 20px; - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - transition: border-color 200ms ease, background 200ms ease; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 8px; + padding: 24px 16px 20px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + 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 { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(35, 111, 135, 0.3); + border-color: rgba(42, 158, 96, 0.45); } + } - &--win { - border-color: rgba(42, 158, 96, 0.18); - &:hover { border-color: rgba(42, 158, 96, 0.45); } - } + &--loss { + border-color: rgba(173, 10, 5, 0.18); - &--loss { - 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 { - border-color: rgba(246, 125, 82, 0.18); - &:hover { border-color: rgba(246, 125, 82, 0.45); } + &--bomb { + border-color: rgba(246, 125, 82, 0.18); + + &:hover { + border-color: rgba(246, 125, 82, 0.45); } + } } .profile-stat__icon { - font-size: 18px; - color: rgba(255, 255, 255, 0.15); - margin-bottom: 2px; + font-size: 18px; + color: rgba(255, 255, 255, 0.15); + margin-bottom: 2px; - .profile-stat--win & { color: rgba(94, 232, 154, 0.3); } - .profile-stat--loss & { color: rgba(246, 125, 82, 0.3); } - .profile-stat--bomb & { color: rgba(246, 125, 82, 0.25); } + .profile-stat--win & { + color: rgba(94, 232, 154, 0.3); + } + + .profile-stat--loss & { + color: rgba(246, 125, 82, 0.3); + } + + .profile-stat--bomb & { + color: rgba(246, 125, 82, 0.25); + } } .profile-stat__value { - display: block; - font: 800 40px 'Rajdhani', sans-serif; - color: #ffffff; - line-height: 1; + display: block; + font: 800 40px 'Rajdhani', sans-serif; + color: #ffffff; + line-height: 1; - .profile-stat--win & { color: #5ee89a; } - .profile-stat--loss & { color: #f67d52; } - .profile-stat--bomb & { color: rgba(246, 125, 82, 0.8); } + .profile-stat--win & { + color: #5ee89a; + } + + .profile-stat--loss & { + color: #f67d52; + } + + .profile-stat--bomb & { + color: rgba(246, 125, 82, 0.8); + } } .profile-stat__label { - font: 600 10px 'Rajdhani', sans-serif; - text-transform: uppercase; - letter-spacing: 2px; - color: rgba(255, 255, 255, 0.3); + font: 600 10px 'Rajdhani', sans-serif; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(255, 255, 255, 0.3); } .profile-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; + display: flex; + gap: 10px; + flex-wrap: wrap; } .profile-action-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 9px 18px; - background: rgba(35, 111, 135, 0.12); - color: rgba(149, 207, 245, 0.75); - border: 1px solid rgba(35, 111, 135, 0.3); - border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + background: rgba(35, 111, 135, 0.12); + color: rgba(149, 207, 245, 0.75); + border: 1px solid rgba(35, 111, 135, 0.3); + border-radius: 6px; + text-decoration: none; + font: 600 12px 'Rajdhani', sans-serif; + letter-spacing: 1.5px; + text-transform: uppercase; + transition: background 200ms ease, border-color 200ms ease, color 200ms ease; + + i { + font-size: 11px; + opacity: 0.8; + } + + &:hover { + background: rgba(35, 111, 135, 0.22); + border-color: rgba(35, 111, 135, 0.55); + color: rgba(149, 207, 245, 1); text-decoration: none; - font: 600 12px 'Rajdhani', sans-serif; - letter-spacing: 1.5px; - text-transform: uppercase; - transition: background 200ms ease, border-color 200ms ease, color 200ms ease; - - i { font-size: 11px; opacity: 0.8; } - - &:hover { - background: rgba(35, 111, 135, 0.22); - border-color: rgba(35, 111, 135, 0.55); - color: rgba(149, 207, 245, 1); - text-decoration: none; - } + } } .profile-section { - display: flex; - flex-direction: column; - gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; + margin-bottom: 32px; } .profile-section__description { - font-size: 13px; - color: rgba(149, 207, 245, 0.65); - line-height: 1.6; - margin: 0; + font-size: 13px; + color: rgba(149, 207, 245, 0.65); + line-height: 1.6; + margin: 0; } .profile-section__title { - font: 700 11px 'Rajdhani', sans-serif; - text-transform: uppercase; - letter-spacing: 4px; - color: rgba(149, 207, 245, 0.45); - display: flex; - align-items: center; - gap: 8px; + font: 700 18px 'Rajdhani', sans-serif; + text-transform: uppercase; + letter-spacing: 4px; + color: rgba(149, 207, 245, 0.45); + display: flex; + align-items: center; + gap: 8px; - i { opacity: 0.7; } + i { + opacity: 0.7; + } } .profile-games { - display: flex; - flex-direction: column; - gap: 6px; + display: flex; + flex-direction: column; + gap: 6px; } .profile-game { - display: grid; - grid-template-columns: 26px 76px 22px 1fr 18px auto; - align-items: center; - gap: 10px; - padding: 11px 16px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.055); - border-left-width: 3px; - font: 500 14px 'Rajdhani', sans-serif; - transition: background 180ms ease; + display: grid; + grid-template-columns: 26px 76px 22px 1fr 18px auto; + align-items: center; + gap: 10px; + padding: 11px 16px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.055); + border-left-width: 3px; + font: 500 14px 'Rajdhani', sans-serif; + 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); } - &--loss { border-left-color: rgba(173, 10, 5, 0.55); } - &--draw { border-left-color: rgba(149, 207, 245, 0.25); } + &--win { + border-left-color: rgba(42, 158, 96, 0.55); + } + + &--loss { + border-left-color: rgba(173, 10, 5, 0.55); + } + + &--draw { + border-left-color: rgba(149, 207, 245, 0.25); + } } .profile-game__badge { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 4px; - font: 800 10px 'Rajdhani', sans-serif; - letter-spacing: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + font: 800 10px 'Rajdhani', sans-serif; + letter-spacing: 0; - .profile-game--win & { background: rgba(42, 158, 96, 0.18); 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--win & { + background: rgba(42, 158, 96, 0.18); + 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 { - font: 700 14px 'Rajdhani', sans-serif; - color: #fff; - letter-spacing: 1px; + font: 700 14px 'Rajdhani', sans-serif; + color: #fff; + letter-spacing: 1px; } .profile-game__vs { - font: 400 10px 'Rajdhani', sans-serif; - color: rgba(255, 255, 255, 0.22); - text-transform: uppercase; - letter-spacing: 1px; - text-align: center; + font: 400 10px 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.22); + text-transform: uppercase; + letter-spacing: 1px; + text-align: center; } .profile-game__opponent { - color: rgba(149, 207, 245, 0.7); - letter-spacing: 0.5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: rgba(149, 207, 245, 0.7); + letter-spacing: 0.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .profile-game__color { - font-size: 10px; - opacity: 0.6; + font-size: 10px; + opacity: 0.6; } .profile-game__date { - font: 400 11px 'Rajdhani', sans-serif; - color: rgba(255, 255, 255, 0.25); - letter-spacing: 0.5px; - text-align: right; - white-space: nowrap; + font: 400 11px 'Rajdhani', sans-serif; + color: rgba(255, 255, 255, 0.25); + letter-spacing: 0.5px; + text-align: right; + white-space: nowrap; } .profile-empty { - text-align: center; - padding: 48px 20px; - color: rgba(255, 255, 255, 0.25); + text-align: center; + padding: 48px 20px; + color: rgba(255, 255, 255, 0.25); - i { - font-size: 40px; - display: block; - margin-bottom: 16px; - opacity: 0.4; + i { + font-size: 40px; + display: block; + margin-bottom: 16px; + opacity: 0.4; + } + + p { + font: 400 15px 'Rajdhani', sans-serif; + letter-spacing: 0.5px; + } + + a { + color: #95cff5; + text-decoration: none; + font-weight: 600; + transition: color 180ms; + + &:hover { + color: #c5e8ff; } - - p { - font: 400 15px 'Rajdhani', sans-serif; - letter-spacing: 0.5px; - } - - a { - color: #95cff5; - text-decoration: none; - font-weight: 600; - transition: color 180ms; - - &:hover { color: #c5e8ff; } - } -} \ No newline at end of file + } +} diff --git a/assets/css/passkey.scss b/assets/css/passkey.scss index 418d8af..ae93e67 100644 --- a/assets/css/passkey.scss +++ b/assets/css/passkey.scss @@ -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-dark: #1a5a70; $danger: #c0392b; @@ -203,4 +288,4 @@ $bg-hover: rgba(35, 111, 135, 0.15); color: #64b5f6; border: 1px solid rgba(25, 118, 210, 0.3); } -} \ No newline at end of file +} diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx index 78b71d0..3e926c2 100644 --- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -76,7 +76,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const wInit = (revealedCells = []) => { setGridReady(true); - showOverlay('We are waiting for your opponent...', gameAssoc ? ( + showOverlay('Choose an opponent!', gameAssoc ? ( =2.11 <2.14", "doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/orm": "^2.6", + "endroid/qr-code": "^6.1", "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/flex": "^2.10.0", "symfony/form": "7.4.*", diff --git a/composer.lock b/composer.lock index f89d219..35451f9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d8ff385b04f98203cb1c117c260f6c4", + "content-hash": "ce98c65440896f6e3c709b7996e61033", "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", "version": "0.17.0", @@ -65,6 +120,56 @@ ], "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", "version": "2.2.0", @@ -1452,6 +1557,78 @@ ], "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", "version": "5.6.0", @@ -2286,6 +2463,176 @@ }, "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", "version": "3.2.3", @@ -2357,6 +2704,76 @@ ], "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", "version": "1.4.2", diff --git a/config/bundles.php b/config/bundles.php index 4a9df2d..fd036e4 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -13,4 +13,5 @@ return [ Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true], Webauthn\Bundle\WebauthnBundle::class => ['all' => true], Webauthn\Stimulus\WebauthnStimulusBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], ]; diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 0000000..794e6da --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -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 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 17a2604..d730a69 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -17,6 +17,14 @@ security: lazy: true provider: app_user_provider 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: login_path: MineSeekerBundle_login check_path: MineSeekerBundle_login @@ -27,11 +35,9 @@ security: logout: path: MineSeekerBundle_logout target: MineSeekerBundle_homepage - remember_me: - secret: '%kernel.secret%' - lifetime: 604800 - remember_me_parameter: _remember_me + switch_user: false access_control: + - { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } - { path: ^/api/webauthn/authentication/begin, roles: PUBLIC_ACCESS } - { path: ^/api/webauthn/authentication/complete, roles: PUBLIC_ACCESS } diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml new file mode 100644 index 0000000..8b6b807 --- /dev/null +++ b/config/routes/scheb_2fa.yaml @@ -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 diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 942ec8a..533bfde 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -33,8 +33,10 @@ class ProfileController extends AbstractController { public function __construct( private readonly PlayedGameRepository $repo, - private readonly WebAuthnService $webAuthnService - ) { } + private readonly WebAuthnService $webAuthnService + ) + { + } #[Route('/profile', name: 'MineSeekerBundle_profile')] public function index(): Response @@ -62,17 +64,19 @@ class ProfileController extends AbstractController $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $credentials = $this->webAuthnService->getCredentialsForUser($user); - $credentialsData = array_map(fn ($cred) => [ - 'id' => $cred->getId(), - 'credentialName' => $cred->getCredentialName(), - 'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'), - 'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'), - 'isBackupEligible' => $cred->isBackupEligible(), + $credentialsData = array_map(fn($cred) => [ + 'id' => $cred->getId(), + 'credentialName' => $cred->getCredentialName(), + 'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'), + 'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'), + 'isBackupEligible' => $cred->isBackupEligible(), 'isBackupAuthenticated' => $cred->isBackupAuthenticated(), ], $credentials); return $this->render('Security/profile_security.html.twig', [ - 'credentials' => $credentialsData, + 'credentials' => $credentialsData, + 'isTotpEnabled' => $user->isTotpAuthenticationEnabled(), + 'backupCodesCount' => \count($user->getBackupCodes()), ]); } } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index e224d94..71eec0f 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -55,17 +55,17 @@ class SecurityController extends AbstractController } #[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')] public function register( - Request $request, + Request $request, UserPasswordHasherInterface $hasher, - EntityManagerInterface $em, - MailerInterface $mailer, + EntityManagerInterface $em, + MailerInterface $mailer, ): Response { if ($this->getUser()) { return $this->redirectToRoute('MineSeekerBundle_homepage'); @@ -114,10 +114,10 @@ class SecurityController extends AbstractController #[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')] public function forgotPassword( - Request $request, - UserRepository $userRepository, + Request $request, + UserRepository $userRepository, EntityManagerInterface $em, - MailerInterface $mailer, + MailerInterface $mailer, ): Response { if ($this->getUser()) { return $this->redirectToRoute('MineSeekerBundle_homepage'); @@ -128,7 +128,7 @@ class SecurityController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $email = $form->get('email')->getData(); - $user = $userRepository->findOneByEmail($email); + $user = $userRepository->findOneByEmail($email); if ($user && $user->isVerified()) { $token = bin2hex(random_bytes(32)); @@ -167,10 +167,10 @@ class SecurityController extends AbstractController #[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')] public function resetPassword( - string $token, - Request $request, - UserRepository $userRepository, - EntityManagerInterface $em, + string $token, + Request $request, + UserRepository $userRepository, + EntityManagerInterface $em, UserPasswordHasherInterface $hasher, ): Response { $user = $userRepository->findOneByResetToken($token); diff --git a/src/Controller/TwoFactorController.php b/src/Controller/TwoFactorController.php new file mode 100644 index 0000000..d6c3583 --- /dev/null +++ b/src/Controller/TwoFactorController.php @@ -0,0 +1,236 @@ + + * @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; + } +} diff --git a/src/Controller/WebAuthnController.php b/src/Controller/WebAuthnController.php index 8365750..142e8a1 100644 --- a/src/Controller/WebAuthnController.php +++ b/src/Controller/WebAuthnController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Entity\User; +use App\Security\PasskeyToken; use App\Service\WebAuthnService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -19,7 +20,6 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Webauthn\AuthenticatorSelectionCriteria; use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialParameters; @@ -295,7 +295,7 @@ class WebAuthnController extends AbstractController $this->webAuthnService->updateLastUsedAt($credentialId, $user); /** 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); $request->getSession()->set('_security_main', serialize($token)); diff --git a/src/Entity/User.php b/src/Entity/User.php index 793681c..e38b21c 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -18,6 +18,10 @@ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; 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\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -36,7 +40,7 @@ use Symfony\Component\Security\Core\User\UserInterface; #[Entity(repositoryClass: UserRepository::class)] #[UniqueEntity(fields: ['username'], message: 'This username is already taken.')] #[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] private ?int $id = null; @@ -65,6 +69,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Column(type: Types::DATETIME_MUTABLE, nullable: true)] 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 { @@ -169,4 +179,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface $this->resetTokenExpiresAt = $resetTokenExpiresAt; 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; + } } diff --git a/src/EventListener/LoginCaptchaListener.php b/src/EventListener/LoginCaptchaListener.php index 4c771d5..8ff7131 100644 --- a/src/EventListener/LoginCaptchaListener.php +++ b/src/EventListener/LoginCaptchaListener.php @@ -35,8 +35,9 @@ readonly class LoginCaptchaListener { public function __construct( private RecaptchaService $recaptcha, - private RequestStack $requestStack, - ) {} + private RequestStack $requestStack, + ) { + } public function __invoke(CheckPassportEvent $event): void { @@ -46,11 +47,18 @@ readonly class LoginCaptchaListener return; } - $token = $request->request->getString('g-recaptcha-response'); - $remoteIp = (string) $request->getClientIp(); + $path = $request->getPathInfo(); - if (!$this->recaptcha->verify($token, $remoteIp)) { - throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.'); + if ($path === '/2fa_check' || strpos($path, '/2fa') === 0) { + 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.'); } -} \ No newline at end of file +} diff --git a/src/Migrations/2026/04/Version20260412090000.php b/src/Migrations/2026/04/Version20260412090000.php new file mode 100644 index 0000000..919c8ef --- /dev/null +++ b/src/Migrations/2026/04/Version20260412090000.php @@ -0,0 +1,44 @@ + + * @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'); + } +} \ No newline at end of file diff --git a/src/Security/PasskeyToken.php b/src/Security/PasskeyToken.php new file mode 100644 index 0000000..fd3c54f --- /dev/null +++ b/src/Security/PasskeyToken.php @@ -0,0 +1,54 @@ + + * @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); + } +} diff --git a/symfony.lock b/symfony.lock index ee081b3..69914be 100644 --- a/symfony.lock +++ b/symfony.lock @@ -122,6 +122,19 @@ "roave/security-advisories": { "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": { "version": "3.9", "recipe": { diff --git a/templates/Security/2fa.html.twig b/templates/Security/2fa.html.twig new file mode 100644 index 0000000..fc3751f --- /dev/null +++ b/templates/Security/2fa.html.twig @@ -0,0 +1,54 @@ +{% extends 'Game/index.html.twig' %} + +{% block title %} - Two-Factor Authentication{% endblock %} + +{% block body %} +
+
+

Two-Factor Authentication

+

Enter the 6-digit code from your authenticator app

+ + {% if authenticationError is defined and authenticationError %} +
+ + {{ authenticationError|trans({}, 'SchebTwoFactorBundle') }} +
+ {% endif %} + +
+ +
+ +
+ + +
+

Or enter one of your backup codes.

+
+ + +
+ +
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/Security/2fa_setup.html.twig b/templates/Security/2fa_setup.html.twig new file mode 100644 index 0000000..032f499 --- /dev/null +++ b/templates/Security/2fa_setup.html.twig @@ -0,0 +1,63 @@ +{% extends 'Game/index.html.twig' %} + +{% block title %} - Enable Two-Factor Authentication{% endblock %} + +{% block body %} +
+
+

Enable Two-Factor Authentication

+

Scan the QR code with your authenticator app

+ +
+
+ TOTP QR Code +
+ +
+

Can't scan? Enter this key manually:

+ {{ pending_secret }} +
+ +
+ + +
+ +
+ + +
+

Enter the 6-digit code from your app to confirm setup.

+
+ + +
+
+ + +
+
+{% endblock %} diff --git a/templates/Security/profile.html.twig b/templates/Security/profile.html.twig index c4f3fc6..e6cfd93 100644 --- a/templates/Security/profile.html.twig +++ b/templates/Security/profile.html.twig @@ -45,12 +45,6 @@ -
- - Security Settings - -
- {% if recent|length > 0 %}

diff --git a/templates/Security/profile_security.html.twig b/templates/Security/profile_security.html.twig index dda5f59..71e7e7d 100644 --- a/templates/Security/profile_security.html.twig +++ b/templates/Security/profile_security.html.twig @@ -4,18 +4,6 @@ {% block body %}
-
-
- {{ app.user.username|slice(0, 2)|upper }} -
-
-

{{ app.user.username }}

-

- Security Settings -

-
-
-
+ +
+

+ Two-Factor Authentication +

+

+ Add an extra layer of security by requiring a one-time code from your authenticator app each time you sign in + with a password. +

+ + {% set newBackupCodes = app.flashes('2fa_backup_codes') %} + + {% if isTotpEnabled %} +
+ + Two-factor authentication is active. +
+ + {% if newBackupCodes|length > 0 %} +
+

+ + Save these backup codes now — they will not be shown again. +

+
+ {% for code in newBackupCodes[0] %} + {{ code }} + {% endfor %} +
+
+ {% endif %} + +
+
+ + +
+ +
+ + + {{ backupCodesCount }} backup code{{ backupCodesCount != 1 ? 's' : '' }} remaining + +
+ + +
+
+
+ {% else %} +
+ + Two-factor authentication is not enabled. +
+ +
+ + +
+ {% endif %} +
+ +
+

+ Password changing +

+

+ 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. +

+

{% endblock %}