new: usr: implement the 2FA authentication (TOTP and backup codes) #4
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<WaitingOverlayContent
|
||||
shareUrl={`${window.location.href}/${gameAssoc}`}
|
||||
currentGameAssoc={gameAssoc}
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"doctrine/doctrine-bundle": ">=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.*",
|
||||
|
||||
419
composer.lock
generated
419
composer.lock
generated
@@ -4,8 +4,63 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
13
config/packages/scheb_2fa.yaml
Normal file
13
config/packages/scheb_2fa.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
scheb_two_factor:
|
||||
security_tokens:
|
||||
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
|
||||
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
|
||||
|
||||
totp:
|
||||
enabled: true
|
||||
issuer: "Mine Seeker"
|
||||
server_name: "Mine Seeker"
|
||||
template: "Security/2fa.html.twig"
|
||||
|
||||
backup_codes:
|
||||
enabled: true
|
||||
@@ -17,6 +17,14 @@ security:
|
||||
lazy: true
|
||||
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 }
|
||||
|
||||
11
config/routes/scheb_2fa.yaml
Normal file
11
config/routes/scheb_2fa.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
2fa_login:
|
||||
path: /2fa
|
||||
defaults:
|
||||
_controller: "scheb_two_factor.form_controller::form"
|
||||
requirements:
|
||||
_method: GET
|
||||
|
||||
2fa_login_check:
|
||||
path: /2fa_check
|
||||
requirements:
|
||||
_method: POST
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
236
src/Controller/TwoFactorController.php
Normal file
236
src/Controller/TwoFactorController.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Exception\ValidationException;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use RuntimeException;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Class TwoFactorController
|
||||
*
|
||||
* Handles TOTP 2FA setup/teardown and the login challenge form.
|
||||
*
|
||||
* @package App\Controller
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 04. 12.
|
||||
*/
|
||||
#[AsController]
|
||||
class TwoFactorController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TotpAuthenticatorInterface $totpAuthenticator,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
/** 2FA challenge form shown during login when TOTP is enabled. */
|
||||
#[Route('/2fa', name: '2fa_login', methods: ['GET'])]
|
||||
public function loginForm(): Response
|
||||
{
|
||||
return $this->render('Security/2fa.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dummy action — the actual POST is intercepted by the scheb/2fa firewall listener
|
||||
* before it ever reaches a controller. The route must exist so path('2fa_login_check')
|
||||
* resolves in Twig.
|
||||
*/
|
||||
#[Route('/2fa_check', name: '2fa_login_check', methods: ['POST'])]
|
||||
public function loginCheck(): never
|
||||
{
|
||||
throw new \LogicException('This action is intercepted by the 2FA firewall listener.');
|
||||
}
|
||||
|
||||
/** Step 1 — generate a pending secret, store it in the session. */
|
||||
#[Route('/profile/security/2fa/prepare', name: 'MineSeekerBundle_2fa_prepare', methods: ['POST'])]
|
||||
public function prepare(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
if (!$this->isCsrfTokenValid('2fa_prepare', $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$secret = $this->totpAuthenticator->generateSecret();
|
||||
$request->getSession()->set('totp_pending_secret', $secret);
|
||||
|
||||
return $this->redirectToRoute('MineSeekerBundle_2fa_setup');
|
||||
}
|
||||
|
||||
/** Step 2 — show the setup page with QR code and verification form. */
|
||||
#[Route('/profile/security/2fa/setup', name: 'MineSeekerBundle_2fa_setup', methods: ['GET'])]
|
||||
public function setup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
$pendingSecret = $request->getSession()->get('totp_pending_secret');
|
||||
if (!$pendingSecret) {
|
||||
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||
}
|
||||
|
||||
return $this->render('Security/2fa_setup.html.twig', [
|
||||
'pending_secret' => $pendingSecret,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Serve a PNG QR code for the pending TOTP secret. */
|
||||
#[Route('/profile/security/2fa/qr-code', name: 'MineSeekerBundle_2fa_qr_code', methods: ['GET'])]
|
||||
public function qrCode(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$pendingSecret = $request->getSession()->get('totp_pending_secret');
|
||||
|
||||
if (!$pendingSecret) {
|
||||
return new Response('', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$provisioningUri = sprintf(
|
||||
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
|
||||
rawurlencode('Mine Seeker'),
|
||||
rawurlencode($user->getUserIdentifier()),
|
||||
$pendingSecret,
|
||||
rawurlencode('Mine Seeker'),
|
||||
);
|
||||
|
||||
try {
|
||||
$result = new Builder(
|
||||
writer: new PngWriter(),
|
||||
data: $provisioningUri,
|
||||
size: 220,
|
||||
margin: 10,
|
||||
)->build();
|
||||
} catch (ValidationException $e) {
|
||||
throw new RuntimeException("Failed to generate QR code: $e->getMessage()", 0, $e);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
$result->getString(),
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'image/png', 'Cache-Control' => 'no-cache, no-store, must-revalidate'],
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 3 — verify the first code and save the secret. */
|
||||
#[Route('/profile/security/2fa/enable', name: 'MineSeekerBundle_2fa_enable', methods: ['POST'])]
|
||||
public function enable(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
if (!$this->isCsrfTokenValid('2fa_enable', $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$pendingSecret = $request->getSession()->get('totp_pending_secret');
|
||||
|
||||
if (!$pendingSecret) {
|
||||
$this->addFlash('error', 'No pending 2FA setup found. Please start again.');
|
||||
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||
}
|
||||
|
||||
$code = $request->request->getString('_auth_code');
|
||||
|
||||
// Temporarily set the pending secret to verify the code
|
||||
$user->setTotpSecret($pendingSecret);
|
||||
|
||||
if (!$this->totpAuthenticator->checkCode($user, $code)) {
|
||||
$user->setTotpSecret(null);
|
||||
$this->addFlash('error', 'Invalid verification code. Please try again.');
|
||||
return $this->redirectToRoute('MineSeekerBundle_2fa_setup');
|
||||
}
|
||||
|
||||
$backupCodes = $this->generateBackupCodes();
|
||||
$user->setBackupCodes($backupCodes);
|
||||
$this->em->flush();
|
||||
|
||||
$request->getSession()->remove('totp_pending_secret');
|
||||
$this->addFlash('2fa_backup_codes', $backupCodes);
|
||||
$this->addFlash('success', 'Two-factor authentication has been enabled.');
|
||||
|
||||
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||
}
|
||||
|
||||
/** Disable TOTP for the current user. */
|
||||
#[Route('/profile/security/2fa/disable', name: 'MineSeekerBundle_2fa_disable', methods: ['POST'])]
|
||||
public function disable(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
if (!$this->isCsrfTokenValid('2fa_disable', $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$user->setTotpSecret(null);
|
||||
$user->setBackupCodes([]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Two-factor authentication has been disabled.');
|
||||
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||
}
|
||||
|
||||
/** Regenerate backup codes for the current user. */
|
||||
#[Route('/profile/security/2fa/backup-codes/regenerate', name: 'MineSeekerBundle_2fa_backup_regenerate', methods: ['POST'])]
|
||||
public function regenerateBackupCodes(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
if (!$this->isCsrfTokenValid('2fa_backup_regen', $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
if (!$user->isTotpAuthenticationEnabled()) {
|
||||
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||
}
|
||||
|
||||
$backupCodes = $this->generateBackupCodes();
|
||||
$user->setBackupCodes($backupCodes);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('2fa_backup_codes', $backupCodes);
|
||||
$this->addFlash('success', 'Backup codes have been regenerated. Save them somewhere safe.');
|
||||
|
||||
return $this->redirectToRoute('MineSeekerBundle_profile_security');
|
||||
}
|
||||
|
||||
/** @return string[] Eight 8-character uppercase alphanumeric codes */
|
||||
private function generateBackupCodes(int $count = 8): array
|
||||
{
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$codes[] = strtoupper(bin2hex(random_bytes(4)));
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
44
src/Migrations/2026/04/Version20260412090000.php
Normal file
44
src/Migrations/2026/04/Version20260412090000.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Migrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Class Version20260412090000
|
||||
*
|
||||
* @package App\Migrations
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 04. 12.
|
||||
*/
|
||||
final class Version20260412090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add TOTP 2FA and backup codes to User';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE app_user ADD totp_secret VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE app_user ADD backup_codes JSON DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE app_user DROP totp_secret');
|
||||
$this->addSql('ALTER TABLE app_user DROP backup_codes');
|
||||
}
|
||||
}
|
||||
54
src/Security/PasskeyToken.php
Normal file
54
src/Security/PasskeyToken.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Token used for passkey (WebAuthn) authentication.
|
||||
* Intentionally not listed in scheb/2fa security_tokens so passkey logins
|
||||
* bypass the TOTP 2FA challenge — passkeys are already a strong second factor.
|
||||
*
|
||||
* @package App\Security
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 04. 12.
|
||||
*/
|
||||
final class PasskeyToken extends AbstractToken
|
||||
{
|
||||
public function __construct(
|
||||
UserInterface $user,
|
||||
private readonly string $firewallName,
|
||||
array $roles = [],
|
||||
) {
|
||||
parent::__construct($roles);
|
||||
$this->setUser($user);
|
||||
}
|
||||
|
||||
public function getFirewallName(): string
|
||||
{
|
||||
return $this->firewallName;
|
||||
}
|
||||
|
||||
public function __serialize(): array
|
||||
{
|
||||
return [$this->firewallName, parent::__serialize()];
|
||||
}
|
||||
|
||||
public function __unserialize(array $data): void
|
||||
{
|
||||
[$this->firewallName, $parentData] = $data;
|
||||
parent::__unserialize($parentData);
|
||||
}
|
||||
}
|
||||
13
symfony.lock
13
symfony.lock
@@ -122,6 +122,19 @@
|
||||
"roave/security-advisories": {
|
||||
"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": {
|
||||
|
||||
54
templates/Security/2fa.html.twig
Normal file
54
templates/Security/2fa.html.twig
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends 'Game/index.html.twig' %}
|
||||
|
||||
{% block title %} - Two-Factor Authentication{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h2 class="auth-title">Two-Factor Authentication</h2>
|
||||
<p class="auth-sub">Enter the 6-digit code from your authenticator app</p>
|
||||
|
||||
{% if authenticationError is defined and authenticationError %}
|
||||
<div class="auth-error">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
{{ authenticationError|trans({}, 'SchebTwoFactorBundle') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="post" action="{{ path('2fa_login_check') }}">
|
||||
|
||||
<div class="auth-field">
|
||||
<label for="auth-code" class="auth-label">Authentication Code</label>
|
||||
<div class="auth-input-wrap">
|
||||
<i class="fa fa-shield auth-input-icon"></i>
|
||||
<input
|
||||
type="text"
|
||||
id="auth-code"
|
||||
name="_auth_code"
|
||||
class="auth-input auth-input--code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
autofocus
|
||||
required
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<p class="auth-field-hint">Or enter one of your backup codes.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit">
|
||||
<i class="fa fa-check"></i> Verify
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ path('MineSeekerBundle_logout') }}" class="auth-cancel-standalone">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"/>
|
||||
<button type="submit" class="auth-cancel auth-cancel--block">
|
||||
<i class="fa fa-sign-out"></i> Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
63
templates/Security/2fa_setup.html.twig
Normal file
63
templates/Security/2fa_setup.html.twig
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends 'Game/index.html.twig' %}
|
||||
|
||||
{% block title %} - Enable Two-Factor Authentication{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="auth-page">
|
||||
<div class="auth-card auth-card--wide">
|
||||
<h2 class="auth-title">Enable Two-Factor Authentication</h2>
|
||||
<p class="auth-sub">Scan the QR code with your authenticator app</p>
|
||||
|
||||
<div class="twofa-setup">
|
||||
<div class="twofa-setup__qr">
|
||||
<img
|
||||
src="{{ path('MineSeekerBundle_2fa_qr_code') }}"
|
||||
alt="TOTP QR Code"
|
||||
width="220"
|
||||
height="220"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="twofa-setup__manual">
|
||||
<p class="twofa-setup__manual-label">Can't scan? Enter this key manually:</p>
|
||||
<code class="twofa-setup__secret">{{ pending_secret }}</code>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_2fa_enable') }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('2fa_enable') }}"/>
|
||||
|
||||
<div class="auth-field">
|
||||
<label for="auth-code" class="auth-label">Verification Code</label>
|
||||
<div class="auth-input-wrap">
|
||||
<i class="fa fa-shield auth-input-icon"></i>
|
||||
<input
|
||||
type="text"
|
||||
id="auth-code"
|
||||
name="_auth_code"
|
||||
class="auth-input auth-input--code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
autofocus
|
||||
required
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<p class="auth-field-hint">Enter the 6-digit code from your app to confirm setup.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit">
|
||||
<i class="fa fa-check"></i> Activate 2FA
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="auth-back">
|
||||
<a href="{{ path('MineSeekerBundle_profile_security') }}">
|
||||
<i class="fa fa-chevron-left"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -45,12 +45,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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 %}
|
||||
<div class="profile-section">
|
||||
<h2 class="profile-section__title">
|
||||
|
||||
@@ -4,18 +4,6 @@
|
||||
|
||||
{% block body %}
|
||||
<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">
|
||||
<a href="{{ path('MineSeekerBundle_profile') }}" class="profile-action-btn">
|
||||
<i class="fa fa-chevron-left"></i> Back to Profile
|
||||
@@ -39,6 +27,83 @@
|
||||
}|json_encode|e('html') }}"
|
||||
></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>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user