✨ Web version login support send factor code
This commit is contained in:
parent
98b2eeb13d
commit
d7b443e678
@ -4,7 +4,7 @@
|
|||||||
ViewData["Title"] = "Profile";
|
ViewData["Title"] = "Profile";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12">
|
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12">
|
||||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-2xl">
|
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-2xl">
|
||||||
<h1 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">User Profile</h1>
|
<h1 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">User Profile</h1>
|
||||||
|
|
||||||
|
@ -1,54 +1,13 @@
|
|||||||
@page "/web/auth/challenge/{id:guid}"
|
@page "/web/auth/challenge/{id:guid}"
|
||||||
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
|
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Challenge";
|
// This page is kept for backward compatibility
|
||||||
|
// It will automatically redirect to the new SelectFactor page
|
||||||
|
Response.Redirect($"/web/auth/challenge/{Model.Id}/select-factor");
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
|
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md text-center">
|
||||||
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Authentication Challenge</h1>
|
<p>Redirecting to authentication page...</p>
|
||||||
|
|
||||||
@if (Model.AuthChallenge == null)
|
|
||||||
{
|
|
||||||
<p class="text-red-500 text-center">Challenge not found or expired.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Remaining steps: @Model.AuthChallenge.StepRemain</p>
|
|
||||||
|
|
||||||
@if (Model.AuthChallenge.StepRemain > 0)
|
|
||||||
{
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" asp-for="Id"/>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label asp-for="SelectedFactorId"
|
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select
|
|
||||||
Factor:</label>
|
|
||||||
<select asp-for="SelectedFactorId" asp-items="Model.AuthFactors"
|
|
||||||
class="form-select mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"></select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label asp-for="Secret"
|
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
|
|
||||||
<input asp-for="Secret"
|
|
||||||
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
|
|
||||||
type="password"/>
|
|
||||||
<span asp-validation-for="Secret" class="text-red-500 text-sm mt-1"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit"
|
|
||||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
|
||||||
}
|
|
@ -1,185 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using DysonNetwork.Sphere.Auth;
|
|
||||||
using DysonNetwork.Sphere.Account;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Pages.Auth
|
namespace DysonNetwork.Sphere.Pages.Auth
|
||||||
{
|
{
|
||||||
public class ChallengeModel(
|
public class ChallengeModel() : PageModel
|
||||||
AppDatabase db,
|
|
||||||
AccountService accounts,
|
|
||||||
AuthService auth,
|
|
||||||
ActionLogService als,
|
|
||||||
IConfiguration configuration
|
|
||||||
)
|
|
||||||
: PageModel
|
|
||||||
{
|
{
|
||||||
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
public Challenge? AuthChallenge { get; set; }
|
public IActionResult OnGet()
|
||||||
|
|
||||||
[BindProperty] public Guid SelectedFactorId { get; set; }
|
|
||||||
|
|
||||||
[BindProperty] [Required] public string Secret { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public List<SelectListItem> AuthFactors { get; set; } = new();
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
{
|
||||||
await LoadChallengeAndFactors();
|
return RedirectToPage("SelectFactor", new { id = Id });
|
||||||
if (AuthChallenge == null) return NotFound();
|
|
||||||
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
await LoadChallengeAndFactors();
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == Id);
|
|
||||||
if (challenge is null) return NotFound("Auth challenge was not found.");
|
|
||||||
|
|
||||||
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
|
|
||||||
if (factor is null) return NotFound("Auth factor was not found.");
|
|
||||||
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
|
|
||||||
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
|
|
||||||
|
|
||||||
if (challenge.StepRemain == 0) return Page(); // Challenge already completed
|
|
||||||
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, "Challenge expired.");
|
|
||||||
await LoadChallengeAndFactors();
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (await accounts.VerifyFactorCode(factor, Secret))
|
|
||||||
{
|
|
||||||
challenge.StepRemain -= factor.Trustworthy;
|
|
||||||
challenge.StepRemain = Math.Max(0, challenge.StepRemain);
|
|
||||||
challenge.BlacklistFactors.Add(factor.Id);
|
|
||||||
db.Update(challenge);
|
|
||||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
|
||||||
new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "challenge_id", challenge.Id },
|
|
||||||
{ "factor_id", factor.Id }
|
|
||||||
}, Request, challenge.Account
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Invalid password.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
challenge.FailedAttempts++;
|
|
||||||
db.Update(challenge);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
|
||||||
new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "challenge_id", challenge.Id },
|
|
||||||
{ "factor_id", factor.Id }
|
|
||||||
}, Request, challenge.Account
|
|
||||||
);
|
|
||||||
ModelState.AddModelError(string.Empty, ex.Message);
|
|
||||||
await LoadChallengeAndFactors();
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (challenge.StepRemain == 0)
|
|
||||||
{
|
|
||||||
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
|
||||||
new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "challenge_id", challenge.Id },
|
|
||||||
{ "account_id", challenge.AccountId }
|
|
||||||
}, Request, challenge.Account
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
AuthChallenge = challenge;
|
|
||||||
|
|
||||||
if (AuthChallenge.StepRemain == 0)
|
|
||||||
{
|
|
||||||
return await ExchangeTokenAndRedirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
await LoadChallengeAndFactors();
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadChallengeAndFactors()
|
|
||||||
{
|
|
||||||
var challenge = await db.AuthChallenges
|
|
||||||
.Include(e => e.Account)
|
|
||||||
.ThenInclude(e => e.AuthFactors)
|
|
||||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
|
||||||
|
|
||||||
AuthChallenge = challenge;
|
|
||||||
|
|
||||||
if (AuthChallenge != null)
|
|
||||||
{
|
|
||||||
var factorsResponse = AuthChallenge.Account.AuthFactors
|
|
||||||
.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
AuthFactors = factorsResponse.Select(f => new SelectListItem
|
|
||||||
{
|
|
||||||
Value = f.Id.ToString(),
|
|
||||||
Text = f.Type.ToString() // You might want a more user-friendly display for factor types
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IActionResult> ExchangeTokenAndRedirect()
|
|
||||||
{
|
|
||||||
var challenge = await db.AuthChallenges
|
|
||||||
.Include(e => e.Account)
|
|
||||||
.Where(e => e.Id == Id)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (challenge is null) return BadRequest("Authorization code not found or expired.");
|
|
||||||
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
|
|
||||||
|
|
||||||
var session = await db.AuthSessions
|
|
||||||
.Where(e => e.Challenge == challenge)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (session is not null) return BadRequest("Session already exists for this challenge.");
|
|
||||||
|
|
||||||
session = new Session
|
|
||||||
{
|
|
||||||
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
|
||||||
Account = challenge.Account,
|
|
||||||
Challenge = challenge,
|
|
||||||
};
|
|
||||||
|
|
||||||
db.AuthSessions.Add(session);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var tk = auth.CreateToken(session);
|
|
||||||
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions()
|
|
||||||
{
|
|
||||||
HttpOnly = true,
|
|
||||||
Secure = !configuration.GetValue<bool>("Debug"),
|
|
||||||
SameSite = SameSiteMode.Strict,
|
|
||||||
Path = "/"
|
|
||||||
});
|
|
||||||
return RedirectToPage("/Account/Profile"); // Redirect to profile page
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,7 +4,7 @@
|
|||||||
ViewData["Title"] = "Login";
|
ViewData["Title"] = "Login";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
|
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
|
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
|
||||||
|
|
||||||
|
65
DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml
Normal file
65
DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
@page "/web/auth/challenge/{id:guid}/select-factor"
|
||||||
|
@using DysonNetwork.Sphere.Account
|
||||||
|
@model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Select Authentication Method";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||||
|
<div class="bg-white dark:bg-gray-800 px-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Select Authentication Method</h1>
|
||||||
|
|
||||||
|
@if (Model.AuthChallenge == null)
|
||||||
|
{
|
||||||
|
<p class="text-red-500 text-center">Challenge not found or expired.</p>
|
||||||
|
}
|
||||||
|
else if (Model.AuthChallenge.StepRemain == 0)
|
||||||
|
{
|
||||||
|
<p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 mb-4">Please select an authentication method:</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
@foreach (var factor in Model.AuthFactors)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="SelectFactor" class="w-full">
|
||||||
|
<input type="hidden" name="factorId" value="@factor.Id"/>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full text-left p-4 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||||
|
<div
|
||||||
|
class="font-medium text-gray-900 dark:text-white">@GetFactorDisplayName(factor.Type)</div>
|
||||||
|
<div
|
||||||
|
class="text-sm text-gray-500 dark:text-gray-400">@GetFactorDescription(factor.Type)</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
|
||||||
|
private string GetFactorDisplayName(AccountAuthFactorType type) => type switch
|
||||||
|
{
|
||||||
|
AccountAuthFactorType.InAppCode => "Authenticator App",
|
||||||
|
AccountAuthFactorType.EmailCode => "Email",
|
||||||
|
AccountAuthFactorType.TimedCode => "Timed Code",
|
||||||
|
AccountAuthFactorType.PinCode => "PIN Code",
|
||||||
|
AccountAuthFactorType.Password => "Password",
|
||||||
|
_ => type.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
private string GetFactorDescription(AccountAuthFactorType type) => type switch
|
||||||
|
{
|
||||||
|
AccountAuthFactorType.InAppCode => "Enter a code from your authenticator app",
|
||||||
|
AccountAuthFactorType.EmailCode => "Receive a verification code via email",
|
||||||
|
AccountAuthFactorType.TimedCode => "Use a time-based verification code",
|
||||||
|
AccountAuthFactorType.PinCode => "Enter your PIN code",
|
||||||
|
AccountAuthFactorType.Password => "Enter your password",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
78
DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs
Normal file
78
DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DysonNetwork.Sphere.Auth;
|
||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Pages.Auth;
|
||||||
|
|
||||||
|
public class SelectFactorModel(
|
||||||
|
AppDatabase db,
|
||||||
|
AccountService accounts
|
||||||
|
)
|
||||||
|
: PageModel
|
||||||
|
{
|
||||||
|
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Challenge? AuthChallenge { get; set; }
|
||||||
|
public List<AccountAuthFactor> AuthFactors { get; set; } = [];
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
await LoadChallengeAndFactors();
|
||||||
|
if (AuthChallenge == null) return NotFound();
|
||||||
|
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSelectFactorAsync(Guid factorId)
|
||||||
|
{
|
||||||
|
var challenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
|
if (challenge == null) return NotFound();
|
||||||
|
|
||||||
|
var factor = await db.AccountAuthFactors.FindAsync(factorId);
|
||||||
|
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
|
||||||
|
return BadRequest("Invalid authentication method.");
|
||||||
|
|
||||||
|
// For OTP factors that require code delivery
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.SendFactorCode(challenge.Account, factor);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, "An error occurred while sending the verification code.");
|
||||||
|
await LoadChallengeAndFactors();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to verify the page with the selected factor
|
||||||
|
return RedirectToPage("VerifyFactor", new { id = Id, factorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadChallengeAndFactors()
|
||||||
|
{
|
||||||
|
AuthChallenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.ThenInclude(e => e.AuthFactors)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
|
if (AuthChallenge != null)
|
||||||
|
{
|
||||||
|
AuthFactors = AuthChallenge.Account.AuthFactors
|
||||||
|
.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 })
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ExchangeTokenAndRedirect()
|
||||||
|
{
|
||||||
|
// This method is kept for backward compatibility
|
||||||
|
// The actual token exchange is now handled in the VerifyFactor page
|
||||||
|
await Task.CompletedTask; // Add this to fix the async warning
|
||||||
|
return RedirectToPage("/Account/Profile");
|
||||||
|
}
|
||||||
|
}
|
76
DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml
Normal file
76
DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}"
|
||||||
|
@using DysonNetwork.Sphere.Account
|
||||||
|
@model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Verify Your Identity";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||||
|
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">Verify Your Identity</h1>
|
||||||
|
<p class="text-center text-gray-600 dark:text-gray-300 mb-6">
|
||||||
|
@switch (Model.FactorType)
|
||||||
|
{
|
||||||
|
case AccountAuthFactorType.EmailCode:
|
||||||
|
<span>We've sent a verification code to your email.</span>
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.InAppCode:
|
||||||
|
<span>Enter the code from your authenticator app.</span>
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.TimedCode:
|
||||||
|
<span>Enter your time-based verification code.</span>
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.PinCode:
|
||||||
|
<span>Enter your PIN code.</span>
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.Password:
|
||||||
|
<span>Enter your password.</span>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span>Please verify your identity.</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (Model.AuthChallenge == null)
|
||||||
|
{
|
||||||
|
<p class="text-red-500 text-center">Challenge not found or expired.</p>
|
||||||
|
}
|
||||||
|
else if (Model.AuthChallenge.StepRemain == 0)
|
||||||
|
{
|
||||||
|
<p class="text-green-600 dark:text-green-400 text-center">Verification successful. Redirecting...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-red-500 text-sm"></div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label asp-for="Code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
@(Model.FactorType == AccountAuthFactorType.Password ? "Use your password" : "Verification Code")
|
||||||
|
</label>
|
||||||
|
<input asp-for="Code"
|
||||||
|
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
autofocus />
|
||||||
|
<span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a asp-page="SelectFactor" asp-route-id="@Model.Id" class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
← Back to authentication methods
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
||||||
|
}
|
187
DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs
Normal file
187
DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DysonNetwork.Sphere.Auth;
|
||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Pages.Auth
|
||||||
|
{
|
||||||
|
public class VerifyFactorModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDatabase _db;
|
||||||
|
private readonly AccountService _accounts;
|
||||||
|
private readonly AuthService _auth;
|
||||||
|
private readonly ActionLogService _als;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public Guid FactorId { get; set; }
|
||||||
|
|
||||||
|
[BindProperty, Required]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Challenge? AuthChallenge { get; set; }
|
||||||
|
public AccountAuthFactor? Factor { get; set; }
|
||||||
|
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
|
||||||
|
|
||||||
|
public VerifyFactorModel(
|
||||||
|
AppDatabase db,
|
||||||
|
AccountService accounts,
|
||||||
|
AuthService auth,
|
||||||
|
ActionLogService als,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_accounts = accounts;
|
||||||
|
_auth = auth;
|
||||||
|
_als = als;
|
||||||
|
_configuration = configuration;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
await LoadChallengeAndFactor();
|
||||||
|
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
|
||||||
|
if (Factor == null) return NotFound("Authentication method not found.");
|
||||||
|
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
await LoadChallengeAndFactor();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadChallengeAndFactor();
|
||||||
|
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
|
||||||
|
if (Factor == null) return NotFound("Authentication method not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await _accounts.VerifyFactorCode(Factor, Code))
|
||||||
|
{
|
||||||
|
AuthChallenge.StepRemain -= Factor.Trustworthy;
|
||||||
|
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
|
||||||
|
AuthChallenge.BlacklistFactors.Add(Factor.Id);
|
||||||
|
_db.Update(AuthChallenge);
|
||||||
|
|
||||||
|
_als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "challenge_id", AuthChallenge.Id },
|
||||||
|
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
|
||||||
|
}, Request, AuthChallenge.Account);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (AuthChallenge.StepRemain == 0)
|
||||||
|
{
|
||||||
|
_als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "challenge_id", AuthChallenge.Id },
|
||||||
|
{ "account_id", AuthChallenge.AccountId }
|
||||||
|
}, Request, AuthChallenge.Account);
|
||||||
|
|
||||||
|
return await ExchangeTokenAndRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If more steps are needed, redirect back to select factor
|
||||||
|
return RedirectToPage("SelectFactor", new { id = Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Invalid verification code.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (AuthChallenge != null)
|
||||||
|
{
|
||||||
|
AuthChallenge.FailedAttempts++;
|
||||||
|
_db.Update(AuthChallenge);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "challenge_id", AuthChallenge.Id },
|
||||||
|
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
|
||||||
|
}, Request, AuthChallenge.Account);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ModelState.AddModelError(string.Empty, ex.Message);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadChallengeAndFactor()
|
||||||
|
{
|
||||||
|
AuthChallenge = await _db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
|
if (AuthChallenge?.Account != null)
|
||||||
|
{
|
||||||
|
Factor = await _db.AccountAuthFactors
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == FactorId &&
|
||||||
|
e.AccountId == AuthChallenge.Account.Id &&
|
||||||
|
e.EnabledAt != null &&
|
||||||
|
e.Trustworthy > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ExchangeTokenAndRedirect()
|
||||||
|
{
|
||||||
|
var challenge = await _db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
|
if (challenge == null) return BadRequest("Authorization code not found or expired.");
|
||||||
|
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
|
||||||
|
|
||||||
|
var session = await _db.AuthSessions
|
||||||
|
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
|
||||||
|
|
||||||
|
if (session != null) return BadRequest("Session already exists for this challenge.");
|
||||||
|
|
||||||
|
session = new Session
|
||||||
|
{
|
||||||
|
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
||||||
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
||||||
|
Account = challenge.Account,
|
||||||
|
Challenge = challenge,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.AuthSessions.Add(session);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var token = _auth.CreateToken(session);
|
||||||
|
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new()
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = !_configuration.GetValue<bool>("Debug"),
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Path = "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return RedirectToPage("/Account/Profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -20,10 +20,12 @@
|
|||||||
--color-green-600: oklch(62.7% 0.194 149.214);
|
--color-green-600: oklch(62.7% 0.194 149.214);
|
||||||
--color-green-800: oklch(44.8% 0.119 151.328);
|
--color-green-800: oklch(44.8% 0.119 151.328);
|
||||||
--color-green-900: oklch(39.3% 0.095 152.535);
|
--color-green-900: oklch(39.3% 0.095 152.535);
|
||||||
|
--color-blue-300: oklch(80.9% 0.105 251.813);
|
||||||
--color-blue-400: oklch(70.7% 0.165 254.624);
|
--color-blue-400: oklch(70.7% 0.165 254.624);
|
||||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||||
--color-blue-600: oklch(54.6% 0.245 262.881);
|
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||||
|
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||||
--color-gray-100: oklch(96.7% 0.003 264.542);
|
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||||
--color-gray-300: oklch(87.2% 0.01 258.338);
|
--color-gray-300: oklch(87.2% 0.01 258.338);
|
||||||
@ -349,6 +351,9 @@
|
|||||||
.transform {
|
.transform {
|
||||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||||
}
|
}
|
||||||
|
.resize {
|
||||||
|
resize: both;
|
||||||
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@ -361,6 +366,13 @@
|
|||||||
.justify-center {
|
.justify-center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.space-y-4 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.gap-x-6 {
|
.gap-x-6 {
|
||||||
column-gap: calc(var(--spacing) * 6);
|
column-gap: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@ -392,6 +404,9 @@
|
|||||||
.bg-blue-600 {
|
.bg-blue-600 {
|
||||||
background-color: var(--color-blue-600);
|
background-color: var(--color-blue-600);
|
||||||
}
|
}
|
||||||
|
.bg-gray-50 {
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
}
|
||||||
.bg-gray-100 {
|
.bg-gray-100 {
|
||||||
background-color: var(--color-gray-100);
|
background-color: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
@ -425,6 +440,9 @@
|
|||||||
.px-6 {
|
.px-6 {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.px-8 {
|
||||||
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.py-2 {
|
.py-2 {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@ -437,6 +455,9 @@
|
|||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
.text-2xl {
|
.text-2xl {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||||
@ -481,6 +502,9 @@
|
|||||||
--tw-tracking: var(--tracking-tight);
|
--tw-tracking: var(--tracking-tight);
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
}
|
}
|
||||||
|
.text-blue-600 {
|
||||||
|
color: var(--color-blue-600);
|
||||||
|
}
|
||||||
.text-gray-500 {
|
.text-gray-500 {
|
||||||
color: var(--color-gray-500);
|
color: var(--color-gray-500);
|
||||||
}
|
}
|
||||||
@ -536,6 +560,10 @@
|
|||||||
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
}
|
}
|
||||||
|
.outline {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 1px;
|
||||||
|
}
|
||||||
.blur {
|
.blur {
|
||||||
--tw-blur: blur(8px);
|
--tw-blur: blur(8px);
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
@ -572,6 +600,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-gray-100 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-gray-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-red-700 {
|
.hover\:bg-red-700 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@ -579,6 +614,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:text-blue-500 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--color-blue-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:text-blue-600 {
|
.hover\:text-blue-600 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@ -667,6 +709,11 @@
|
|||||||
background-color: var(--color-yellow-900);
|
background-color: var(--color-yellow-900);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:text-blue-400 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
color: var(--color-blue-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:text-gray-200 {
|
.dark\:text-gray-200 {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
color: var(--color-gray-200);
|
color: var(--color-gray-200);
|
||||||
@ -702,6 +749,24 @@
|
|||||||
color: var(--color-yellow-200);
|
color: var(--color-yellow-200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:hover\:bg-gray-600 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dark\:hover\:text-blue-300 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--color-blue-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:hover\:text-blue-400 {
|
.dark\:hover\:text-blue-400 {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -962,6 +1027,11 @@
|
|||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
}
|
}
|
||||||
|
@property --tw-space-y-reverse {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@ -1044,6 +1114,11 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0 0 #0000;
|
initial-value: 0 0 #0000;
|
||||||
}
|
}
|
||||||
|
@property --tw-outline-style {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: solid;
|
||||||
|
}
|
||||||
@property --tw-blur {
|
@property --tw-blur {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@ -1120,6 +1195,7 @@
|
|||||||
--tw-rotate-z: initial;
|
--tw-rotate-z: initial;
|
||||||
--tw-skew-x: initial;
|
--tw-skew-x: initial;
|
||||||
--tw-skew-y: initial;
|
--tw-skew-y: initial;
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
--tw-leading: initial;
|
--tw-leading: initial;
|
||||||
--tw-font-weight: initial;
|
--tw-font-weight: initial;
|
||||||
@ -1138,6 +1214,7 @@
|
|||||||
--tw-ring-offset-width: 0px;
|
--tw-ring-offset-width: 0px;
|
||||||
--tw-ring-offset-color: #fff;
|
--tw-ring-offset-color: #fff;
|
||||||
--tw-ring-offset-shadow: 0 0 #0000;
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-outline-style: solid;
|
||||||
--tw-blur: initial;
|
--tw-blur: initial;
|
||||||
--tw-brightness: initial;
|
--tw-brightness: initial;
|
||||||
--tw-contrast: initial;
|
--tw-contrast: initial;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user