✨ Web version login
This commit is contained in:
54
DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml
Normal file
54
DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml
Normal file
@@ -0,0 +1,54 @@
|
||||
@page "/web/auth/challenge/{id:guid}"
|
||||
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
|
||||
@{
|
||||
ViewData["Title"] = "Challenge";
|
||||
}
|
||||
|
||||
<div class="min-h-screen 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-6">Authentication Challenge</h1>
|
||||
|
||||
@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>
|
||||
|
||||
@section Scripts {
|
||||
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
||||
}
|
185
DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml.cs
Normal file
185
DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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
|
||||
{
|
||||
public class ChallengeModel(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
ActionLogService als,
|
||||
IConfiguration configuration
|
||||
)
|
||||
: PageModel
|
||||
{
|
||||
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||
|
||||
public Challenge? AuthChallenge { get; set; }
|
||||
|
||||
[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();
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
29
DysonNetwork.Sphere/Pages/Auth/Login.cshtml
Normal file
29
DysonNetwork.Sphere/Pages/Auth/Login.cshtml
Normal file
@@ -0,0 +1,29 @@
|
||||
@page "/web/auth/login"
|
||||
@model DysonNetwork.Sphere.Pages.Auth.LoginModel
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
}
|
||||
|
||||
<div class="min-h-screen 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-6">Login</h1>
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-4">
|
||||
<label asp-for="Username"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
|
||||
<input asp-for="Username"
|
||||
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"/>
|
||||
<span asp-validation-for="Username" 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">
|
||||
Next
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
||||
}
|
81
DysonNetwork.Sphere/Pages/Auth/Login.cshtml.cs
Normal file
81
DysonNetwork.Sphere/Pages/Auth/Login.cshtml.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Auth;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Connection;
|
||||
using NodaTime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Pages.Auth
|
||||
{
|
||||
public class LoginModel(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
GeoIpService geo,
|
||||
ActionLogService als
|
||||
) : PageModel
|
||||
{
|
||||
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return Page();
|
||||
}
|
||||
|
||||
var account = await accounts.LookupAccount(Username);
|
||||
if (account is null)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Account was not found.");
|
||||
return Page();
|
||||
}
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
|
||||
var existingChallenge = await db.AuthChallenges
|
||||
.Where(e => e.Account == account)
|
||||
.Where(e => e.IpAddress == ipAddress)
|
||||
.Where(e => e.UserAgent == userAgent)
|
||||
.Where(e => e.StepRemain > 0)
|
||||
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existingChallenge is not null)
|
||||
{
|
||||
return RedirectToPage("Challenge", new { id = existingChallenge.Id });
|
||||
}
|
||||
|
||||
var challenge = new Challenge
|
||||
{
|
||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
||||
Platform = ChallengePlatform.Web,
|
||||
Audiences = new List<string>(),
|
||||
Scopes = new List<string>(),
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
Location = geo.GetPointFromIp(ipAddress),
|
||||
DeviceId = "web-browser",
|
||||
AccountId = account.Id
|
||||
}.Normalize();
|
||||
|
||||
await db.AuthChallenges.AddAsync(challenge);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
|
||||
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
|
||||
);
|
||||
|
||||
return RedirectToPage("Challenge", new { id = challenge.Id });
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user