Skip to content

Commit 868edcf

Browse files
committed
add 2fa
1 parent a76de07 commit 868edcf

19 files changed

Lines changed: 971 additions & 5 deletions

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
1616
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
1717
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
18+
<PackageVersion Include="QRCoder" Version="1.7.0" />
1819
<PackageVersion Include="SixLabors.Fonts" Version="2.1.3" />
1920
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
2021
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
@page
2+
@model LoginWith2faModel
3+
@{
4+
ViewData["Title"] = "Two-factor verification";
5+
ViewData["Description"] = "Verify your identity with an authenticator code to finish signing in.";
6+
}
7+
8+
<section class="relative flex justify-between gap-8 rounded-2xl border border-border bg-gradient-to-br from-card to-secondary p-10 overflow-hidden isolate max-md:flex-col max-md:p-7">
9+
<div class="absolute -top-[40%] -right-[15%] w-[55%] h-[180%] bg-[radial-gradient(ellipse,rgba(129,140,248,0.08),transparent_70%)] pointer-events-none"></div>
10+
<div class="relative z-10">
11+
<p class="font-mono text-xs font-medium tracking-[0.12em] uppercase text-primary">Account</p>
12+
<h1 class="mt-1.5 mb-3 font-display text-[clamp(1.75rem,3.5vw,2.75rem)] font-bold tracking-tight leading-[1.3] text-white">Verify it&rsquo;s you</h1>
13+
<p class="max-w-xl text-muted-foreground leading-relaxed text-[0.95rem]">
14+
Enter the 6-digit code from your authenticator app to finish signing in.
15+
</p>
16+
</div>
17+
</section>
18+
19+
<section class="grid gap-4 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)] items-start">
20+
<form id="two-factor-form" method="post" asp-route-returnUrl="@Model.ReturnUrl" asp-route-rememberMe="@Model.RememberMe" class="rounded-2xl border border-border bg-card p-6 shadow-sm grid gap-5">
21+
<div asp-validation-summary="ModelOnly" class="text-destructive text-sm" role="alert"></div>
22+
<div class="grid gap-1.5">
23+
<label class="text-[0.78rem] font-semibold text-muted-foreground tracking-wide uppercase" asp-for="Input.AuthenticatorCode">Authenticator Code</label>
24+
<input class="w-full px-3 py-2.5 rounded-md border border-border bg-background text-foreground text-[0.88rem] placeholder:text-muted-foreground/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10 transition-all" asp-for="Input.AuthenticatorCode" autocomplete="one-time-code" inputmode="numeric" placeholder="123456" />
25+
<span asp-validation-for="Input.AuthenticatorCode" class="text-destructive text-xs"></span>
26+
<p class="m-0 text-xs text-muted-foreground">
27+
Spaces and dashes are ignored.
28+
</p>
29+
</div>
30+
31+
<label class="inline-flex items-center gap-2 text-muted-foreground text-[0.88rem] cursor-pointer">
32+
<input class="accent-primary w-4 h-4" asp-for="Input.RememberMachine" />
33+
@Html.DisplayNameFor(m => m.Input.RememberMachine)
34+
</label>
35+
36+
<button id="login-submit" type="submit" class="w-full inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2.5 text-[0.88rem] font-semibold hover:opacity-90 transition-all hover:-translate-y-px shadow-sm cursor-pointer">
37+
Log in
38+
</button>
39+
</form>
40+
41+
<aside class="rounded-2xl border border-border bg-card p-6 shadow-sm grid gap-4">
42+
<div class="rounded-xl border border-border bg-secondary/30 p-5">
43+
<span class="block text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">Two-factor sign-in</span>
44+
<h2 class="mt-2 text-[0.98rem] font-semibold text-foreground">Use your current authenticator code</h2>
45+
<p class="mt-2 text-sm leading-relaxed text-muted-foreground">
46+
Open Microsoft Authenticator, 1Password, Google Authenticator, or another TOTP app and enter the current code for this account.
47+
</p>
48+
</div>
49+
50+
<div class="rounded-xl border border-border bg-secondary/30 p-5">
51+
<span class="block text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">Fallback</span>
52+
<h2 class="mt-2 text-[0.98rem] font-semibold text-foreground">Use a recovery code instead</h2>
53+
<p class="mt-2 text-sm leading-relaxed text-muted-foreground">
54+
If you do not have access to your authenticator device, use one of the recovery codes you saved when 2FA was enabled.
55+
</p>
56+
<a class="mt-4 inline-flex items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-[0.82rem] font-semibold text-foreground hover:bg-secondary/50 transition-colors" asp-page="./LoginWithRecoveryCode" asp-route-returnUrl="@Model.ReturnUrl">
57+
Use recovery code
58+
</a>
59+
</div>
60+
</aside>
61+
</section>
62+
63+
@section Scripts {
64+
<partial name="_ValidationScriptsPartial" />
65+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Identity;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.AspNetCore.Mvc.RazorPages;
6+
using StatusPageSharp.Infrastructure.Identity;
7+
8+
namespace StatusPageSharp.Web.Areas.Identity.Pages.Account;
9+
10+
[AllowAnonymous]
11+
public class LoginWith2faModel(
12+
SignInManager<ApplicationUser> signInManager,
13+
UserManager<ApplicationUser> userManager,
14+
ILogger<LoginWith2faModel> logger
15+
) : PageModel
16+
{
17+
[BindProperty]
18+
public InputModel Input { get; set; } = new();
19+
20+
public bool RememberMe { get; private set; }
21+
22+
public string? ReturnUrl { get; private set; }
23+
24+
public async Task<IActionResult> OnGetAsync(bool rememberMe, string? returnUrl = null)
25+
{
26+
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
27+
if (user is null)
28+
{
29+
return NotFound("Unable to load two-factor authentication user.");
30+
}
31+
32+
ReturnUrl = returnUrl;
33+
RememberMe = rememberMe;
34+
35+
return Page();
36+
}
37+
38+
public async Task<IActionResult> OnPostAsync(bool rememberMe, string? returnUrl = null)
39+
{
40+
if (!ModelState.IsValid)
41+
{
42+
ReturnUrl = returnUrl;
43+
RememberMe = rememberMe;
44+
return Page();
45+
}
46+
47+
returnUrl ??= Url.Content("~/");
48+
49+
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
50+
if (user is null)
51+
{
52+
return NotFound("Unable to load two-factor authentication user.");
53+
}
54+
55+
var authenticatorCode = Input
56+
.AuthenticatorCode.Replace(" ", string.Empty, StringComparison.Ordinal)
57+
.Replace("-", string.Empty, StringComparison.Ordinal);
58+
59+
var result = await signInManager.TwoFactorAuthenticatorSignInAsync(
60+
authenticatorCode,
61+
rememberMe,
62+
Input.RememberMachine
63+
);
64+
65+
var userId = await userManager.GetUserIdAsync(user);
66+
if (result.Succeeded)
67+
{
68+
logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
69+
return LocalRedirect(returnUrl);
70+
}
71+
72+
if (result.IsLockedOut)
73+
{
74+
logger.LogWarning("User account locked out.");
75+
return RedirectToPage("./Lockout");
76+
}
77+
78+
ModelState.AddModelError(
79+
$"{nameof(Input)}.{nameof(Input.AuthenticatorCode)}",
80+
"Invalid authenticator code."
81+
);
82+
ReturnUrl = returnUrl;
83+
RememberMe = rememberMe;
84+
return Page();
85+
}
86+
87+
public class InputModel
88+
{
89+
[Required]
90+
[Display(Name = "Authenticator code")]
91+
[StringLength(7, MinimumLength = 6)]
92+
[DataType(DataType.Text)]
93+
public string AuthenticatorCode { get; set; } = string.Empty;
94+
95+
[Display(Name = "Remember this machine")]
96+
public bool RememberMachine { get; set; }
97+
}
98+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
@page
2+
@model LoginWithRecoveryCodeModel
3+
@{
4+
ViewData["Title"] = "Recovery code sign-in";
5+
ViewData["Description"] = "Use a saved recovery code to finish signing in when your authenticator app is unavailable.";
6+
}
7+
8+
<section class="relative flex justify-between gap-8 rounded-2xl border border-border bg-gradient-to-br from-card to-secondary p-10 overflow-hidden isolate max-md:flex-col max-md:p-7">
9+
<div class="absolute -top-[40%] -right-[15%] w-[55%] h-[180%] bg-[radial-gradient(ellipse,rgba(129,140,248,0.08),transparent_70%)] pointer-events-none"></div>
10+
<div class="relative z-10">
11+
<p class="font-mono text-xs font-medium tracking-[0.12em] uppercase text-primary">Account</p>
12+
<h1 class="mt-1.5 mb-3 font-display text-[clamp(1.75rem,3.5vw,2.75rem)] font-bold tracking-tight leading-[1.3] text-white">Use a recovery code</h1>
13+
<p class="max-w-xl text-muted-foreground leading-relaxed text-[0.95rem]">
14+
Recovery codes are single-use. Enter one exactly as it was saved when you set up two-factor authentication.
15+
</p>
16+
</div>
17+
</section>
18+
19+
<section class="grid gap-4 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)] items-start">
20+
<form method="post" asp-route-returnUrl="@Model.ReturnUrl" class="rounded-2xl border border-border bg-card p-6 shadow-sm grid gap-5">
21+
<div asp-validation-summary="ModelOnly" class="text-destructive text-sm" role="alert"></div>
22+
<div class="grid gap-1.5">
23+
<label class="text-[0.78rem] font-semibold text-muted-foreground tracking-wide uppercase" asp-for="Input.RecoveryCode">Recovery Code</label>
24+
<input class="w-full px-3 py-2.5 rounded-md border border-border bg-background text-foreground text-[0.88rem] placeholder:text-muted-foreground/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10 transition-all" asp-for="Input.RecoveryCode" autocomplete="one-time-code" placeholder="xxxx-xxxx" />
25+
<span asp-validation-for="Input.RecoveryCode" class="text-destructive text-xs"></span>
26+
</div>
27+
28+
<button type="submit" class="w-full inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2.5 text-[0.88rem] font-semibold hover:opacity-90 transition-all hover:-translate-y-px shadow-sm cursor-pointer">
29+
Log in
30+
</button>
31+
</form>
32+
33+
<aside class="rounded-2xl border border-border bg-card p-6 shadow-sm grid gap-4">
34+
<div class="rounded-xl border border-border bg-secondary/30 p-5">
35+
<span class="block text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">Recovery codes</span>
36+
<h2 class="mt-2 text-[0.98rem] font-semibold text-foreground">Only use one when you need it</h2>
37+
<p class="mt-2 text-sm leading-relaxed text-muted-foreground">
38+
Each recovery code can be used once. After signing in, generate a new set if you have used the last remaining codes.
39+
</p>
40+
</div>
41+
42+
<div class="rounded-xl border border-border bg-secondary/30 p-5">
43+
<span class="block text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">Back to app verification</span>
44+
<p class="mt-2 text-sm leading-relaxed text-muted-foreground">
45+
If your authenticator device is available again, use the normal 6-digit code flow instead.
46+
</p>
47+
<a class="mt-4 inline-flex items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-[0.82rem] font-semibold text-foreground hover:bg-secondary/50 transition-colors" asp-page="./LoginWith2fa" asp-route-returnUrl="@Model.ReturnUrl">
48+
Enter authenticator code
49+
</a>
50+
</div>
51+
</aside>
52+
</section>
53+
54+
@section Scripts {
55+
<partial name="_ValidationScriptsPartial" />
56+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Identity;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.AspNetCore.Mvc.RazorPages;
6+
using StatusPageSharp.Infrastructure.Identity;
7+
8+
namespace StatusPageSharp.Web.Areas.Identity.Pages.Account;
9+
10+
[AllowAnonymous]
11+
public class LoginWithRecoveryCodeModel(
12+
SignInManager<ApplicationUser> signInManager,
13+
UserManager<ApplicationUser> userManager,
14+
ILogger<LoginWithRecoveryCodeModel> logger
15+
) : PageModel
16+
{
17+
[BindProperty]
18+
public InputModel Input { get; set; } = new();
19+
20+
public string? ReturnUrl { get; private set; }
21+
22+
public async Task<IActionResult> OnGetAsync(string? returnUrl = null)
23+
{
24+
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
25+
if (user is null)
26+
{
27+
return NotFound("Unable to load two-factor authentication user.");
28+
}
29+
30+
ReturnUrl = returnUrl;
31+
return Page();
32+
}
33+
34+
public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
35+
{
36+
if (!ModelState.IsValid)
37+
{
38+
ReturnUrl = returnUrl;
39+
return Page();
40+
}
41+
42+
returnUrl ??= Url.Content("~/");
43+
44+
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
45+
if (user is null)
46+
{
47+
return NotFound("Unable to load two-factor authentication user.");
48+
}
49+
50+
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty, StringComparison.Ordinal);
51+
var result = await signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
52+
53+
var userId = await userManager.GetUserIdAsync(user);
54+
if (result.Succeeded)
55+
{
56+
logger.LogInformation(
57+
"User with ID '{UserId}' logged in with a recovery code.",
58+
userId
59+
);
60+
return LocalRedirect(returnUrl);
61+
}
62+
63+
if (result.IsLockedOut)
64+
{
65+
logger.LogWarning("User account locked out.");
66+
return RedirectToPage("./Lockout");
67+
}
68+
69+
ModelState.AddModelError(
70+
$"{nameof(Input)}.{nameof(Input.RecoveryCode)}",
71+
"Invalid recovery code."
72+
);
73+
ReturnUrl = returnUrl;
74+
return Page();
75+
}
76+
77+
public class InputModel
78+
{
79+
[Required]
80+
[DataType(DataType.Text)]
81+
[Display(Name = "Recovery code")]
82+
public string RecoveryCode { get; set; } = string.Empty;
83+
}
84+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
@page
2+
@model Disable2faModel
3+
@{
4+
ViewData["Title"] = "Disable two-factor authentication";
5+
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
6+
}
7+
8+
<section class="rounded-2xl border border-border bg-card p-6 shadow-sm">
9+
<div class="flex items-start justify-between gap-4 mb-6 max-md:flex-col max-md:items-start">
10+
<div>
11+
<h2 class="text-lg font-bold tracking-tight m-0">Disable two-factor authentication</h2>
12+
<p class="mt-2 mb-0 max-w-2xl text-sm text-muted-foreground">
13+
This removes the extra sign-in check from your account. Password-only sign-ins are less secure.
14+
</p>
15+
</div>
16+
<a class="inline-flex items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-[0.82rem] font-semibold text-foreground hover:bg-secondary/50 transition-colors" asp-page="./TwoFactorAuthentication">
17+
Cancel
18+
</a>
19+
</div>
20+
21+
<div class="rounded-xl border border-destructive/20 bg-destructive/8 p-5">
22+
<span class="block text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-destructive">Warning</span>
23+
<p class="mt-3 mb-0 text-sm leading-relaxed text-muted-foreground">
24+
Disable 2FA only if you are replacing your authenticator setup or you have lost access to it and need to recover the account.
25+
</p>
26+
</div>
27+
28+
<form method="post" class="flex flex-wrap gap-3 mt-6">
29+
<button class="inline-flex items-center justify-center rounded-md bg-destructive text-primary-foreground px-4 py-2 text-[0.82rem] font-semibold hover:opacity-90 transition-all hover:-translate-y-px shadow-sm cursor-pointer" type="submit">
30+
Disable 2FA
31+
</button>
32+
<a class="inline-flex items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-[0.82rem] font-semibold text-foreground hover:bg-secondary/50 transition-colors" asp-page="./TwoFactorAuthentication">
33+
Keep enabled
34+
</a>
35+
</form>
36+
</section>

0 commit comments

Comments
 (0)