Enhance authentication flow by adding return URL support

This commit updates the authentication process to include a return URL parameter, allowing users to be redirected back to their original page after logging in. Changes were made to the AuthController, Login component, and Routes component to handle the return URL appropriately. Additionally, improvements were made to the TeamScheduler and TeamSchedulerSolution classes for better team and student management. These enhancements improve user experience and navigation within the application.
This commit is contained in:
2026-01-11 13:13:24 -05:00
parent 5a1b3fad2e
commit 6acbc4e852
11 changed files with 74 additions and 94 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ public class TeamScheduler
var m = new int[_students.Length,_teams.Length]; var m = new int[_students.Length,_teams.Length];
foreach (var i in _students) foreach (var i in _students)
foreach (var t in _teams) foreach (var t in _teams)
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0; m[i, t] = _studentObjects[i].Teams.Any(team => team.Id == _teamObjects[t].Id) ? 1 : 0;
// Decision variables: // Decision variables:
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0 // x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
+2 -1
View File
@@ -84,6 +84,7 @@ public class TeamSchedulerSolution(
public Team[] StudentUnassignedTeams(Student student) public Team[] StudentUnassignedTeams(Student student)
{ {
var meetingTeams = TimeSlots.SelectMany(t => t.Teams); var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray(); var meetingTeamIds = meetingTeams.Select(t => t.Id).ToHashSet();
return student.Teams.Where(e => !meetingTeamIds.Contains(e.Id)).ToArray();
} }
} }
+20 -4
View File
@@ -26,7 +26,8 @@ namespace WebApp.Authentication
public async Task<IActionResult> CookieLogin( public async Task<IActionResult> CookieLogin(
[FromForm] string email, [FromForm] string email,
[FromForm] string password, [FromForm] string password,
[FromForm] bool rememberMe = false) [FromForm] bool rememberMe = false,
[FromForm] string? returnUrl = null)
{ {
try try
{ {
@@ -42,7 +43,10 @@ namespace WebApp.Authentication
ipAddress, remaining); ipAddress, remaining);
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes."); var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
return Redirect($"/login?error={errorMsg}"); var redirectUrl = string.IsNullOrEmpty(returnUrl)
? $"/login?error={errorMsg}"
: $"/login?error={errorMsg}&returnUrl={Uri.EscapeDataString(returnUrl)}";
return Redirect(redirectUrl);
} }
// Validate credentials // Validate credentials
@@ -57,7 +61,10 @@ namespace WebApp.Authentication
"Failed login attempt for {Email} from {IpAddress}", "Failed login attempt for {Email} from {IpAddress}",
email, ipAddress); email, ipAddress);
return Redirect("/login?error=Invalid%20email%20or%20password."); var redirectUrl = string.IsNullOrEmpty(returnUrl)
? "/login?error=Invalid%20email%20or%20password."
: $"/login?error=Invalid%20email%20or%20password.&returnUrl={Uri.EscapeDataString(returnUrl)}";
return Redirect(redirectUrl);
} }
// Success - clear rate limit tracking // Success - clear rate limit tracking
@@ -89,13 +96,22 @@ namespace WebApp.Authentication
"Successful login for {Email} ({Role}) from {IpAddress}", "Successful login for {Email} ({Role}) from {IpAddress}",
result.Email, result.Role, ipAddress); result.Email, result.Role, ipAddress);
// Validate return URL is local to prevent open redirect attacks
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return Redirect("/"); return Redirect("/");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error during login process"); _logger.LogError(ex, "Error during login process");
TempData["LoginError"] = "An error occurred. Please try again."; TempData["LoginError"] = "An error occurred. Please try again.";
return Redirect("/login"); var redirectUrl = string.IsNullOrEmpty(returnUrl)
? "/login"
: $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}";
return Redirect(redirectUrl);
} }
} }
@@ -32,6 +32,7 @@
<input type="hidden" id="emailInput" name="email" value="" /> <input type="hidden" id="emailInput" name="email" value="" />
<input type="hidden" id="passwordInput" name="password" value="" /> <input type="hidden" id="passwordInput" name="password" value="" />
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" /> <input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
<input type="hidden" id="returnUrlInput" name="returnUrl" value="@_returnUrl" />
<MudTextField @bind-Value="_loginModel.Email" <MudTextField @bind-Value="_loginModel.Email"
Label="Email" Label="Email"
@@ -75,6 +76,7 @@
private MudInputType _passwordInput = MudInputType.Password; private MudInputType _passwordInput = MudInputType.Password;
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff; private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
private string _antiforgeryToken = string.Empty; private string _antiforgeryToken = string.Empty;
private string? _returnUrl;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -86,13 +88,17 @@
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty; _antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
} }
// Check for error message from query parameter (set by controller on failed login) // Check for error message and returnUrl from query parameters
var uri = new Uri(Navigation.Uri); var uri = new Uri(Navigation.Uri);
var queryParams = QueryHelpers.ParseQuery(uri.Query); var queryParams = QueryHelpers.ParseQuery(uri.Query);
if (queryParams.TryGetValue("error", out var errorValue)) if (queryParams.TryGetValue("error", out var errorValue))
{ {
_errorMessage = errorValue.ToString(); _errorMessage = errorValue.ToString();
} }
if (queryParams.TryGetValue("returnUrl", out var returnUrlValue))
{
_returnUrl = returnUrlValue.ToString();
}
} }
private class LoginModel private class LoginModel
@@ -141,10 +147,12 @@
private async Task HandleFormSubmit() private async Task HandleFormSubmit()
{ {
// Update hidden inputs with current model values, then submit the form // Update hidden inputs with current model values, then submit the form
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
await JS.InvokeVoidAsync("eval", $@" await JS.InvokeVoidAsync("eval", $@"
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)}; document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)}; document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}'; document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
document.getElementById('returnUrlInput').value = {returnUrlValue};
document.getElementById('loginForm').submit(); document.getElementById('loginForm').submit();
"); ");
} }
@@ -1,79 +1 @@
@using Core.Entities
@using MudBlazor
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Event Details</MudText>
</TitleContent>
<DialogContent>
@if (EventOccurrence == null)
{
<MudText>No event data available.</MudText>
}
else
{
<MudStack Spacing="3">
@if (EventDefinition != null)
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Event</MudText>
<MudText Typo="Typo.h6">@EventDefinition.Name</MudText>
</div>
}
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Occurrence</MudText>
<MudText Typo="Typo.body1">@EventOccurrence.Name</MudText>
</div>
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Date & Time</MudText>
<MudText Typo="Typo.body1">
@EventOccurrence.StartTime.ToString("f")
@if (EventOccurrence.EndTime.HasValue)
{
<text> - @EventOccurrence.EndTime.Value.ToString("t")</text>
}
</MudText>
</div>
@if (!string.IsNullOrEmpty(EventOccurrence.Location))
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Location</MudText>
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Size="Size.Small" Class="mr-1" />
@EventOccurrence.Location
</MudText>
</div>
}
@if (!string.IsNullOrEmpty(EventOccurrence.Time))
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Time</MudText>
<MudText Typo="Typo.body1">@EventOccurrence.Time</MudText>
</div>
}
@if (StudentFirstNames != null && StudentFirstNames.Any())
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Students</MudText>
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Small" Class="mr-1" />
@string.Join(", ", StudentFirstNames)
</MudText>
</div>
}
</MudStack>
}
</DialogContent>
</MudDialog>
@code {
[Parameter] public EventOccurrence? EventOccurrence { get; set; }
[Parameter] public EventDefinition? EventDefinition { get; set; }
[Parameter] public List<string>? StudentFirstNames { get; set; }
}
@@ -174,8 +174,9 @@
private async void ToggleRequiredTeam(Team unassignedTeam) private async void ToggleRequiredTeam(Team unassignedTeam)
{ {
if (_scheduledTeams.Contains(unassignedTeam)) var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam); if (scheduledTeamIds.Contains(unassignedTeam.Id))
_scheduledTeams = _scheduledTeams.Where(t => t.Id != unassignedTeam.Id);
else else
{ {
_scheduledTeams = _scheduledTeams.Concat([unassignedTeam]); _scheduledTeams = _scheduledTeams.Concat([unassignedTeam]);
@@ -227,9 +228,11 @@
await Context.Students await Context.Students
.AsNoTracking() .AsNoTracking()
.Include(e => e.Teams) .Include(e => e.Teams)
.ThenInclude(e => e.Captain) .ThenInclude(t => t.Event)
.Include(e => e.Teams)
.ThenInclude(t => t.Captain)
.Include(e => e.EventRankings) .Include(e => e.EventRankings)
.ThenInclude(e => e.EventDefinition) .ThenInclude(e => e.EventDefinition)
.OrderBy(e => e.FirstName).ToArrayAsync(); .OrderBy(e => e.FirstName).ToArrayAsync();
// Load saved selections from localStorage // Load saved selections from localStorage
@@ -5,7 +5,8 @@
<MudText Typo="Typo.h6">@TimeSlotName</MudText> <MudText Typo="Typo.h6">@TimeSlotName</MudText>
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString())) @foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
{ {
var removed = !ScheduledTeams.Contains(team); var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
var removed = !scheduledTeamIds.Contains(team.Id);
<MudLink Typo="Typo.body1" <MudLink Typo="Typo.body1"
Class="d-flex align-center" Class="d-flex align-center"
@@ -14,7 +14,8 @@
@foreach (var unassignedTeam in UnassignedTeams(student)) @foreach (var unassignedTeam in UnassignedTeams(student))
{ {
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer()); var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
var isScheduled = ScheduledTeams.Contains(unassignedTeam); var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id);
var color = isPossibleAddition ? Color.Success : Color.Default; var color = isPossibleAddition ? Color.Success : Color.Default;
if (unassignedTeam != UnassignedTeams(student).First()) if (unassignedTeam != UnassignedTeams(student).First())
@@ -172,7 +172,18 @@
_isLoading = true; _isLoading = true;
try try
{ {
// Load all students with their teams // Load teams with their students separately to avoid circular reference
var teamsWithStudents = await Context.Teams
.AsNoTracking()
.Include(t => t.Students)
.Include(t => t.Event)
.Include(t => t.Captain)
.ToListAsync();
// Create a dictionary for quick lookup of team students
var teamStudentsDict = teamsWithStudents.ToDictionary(t => t.Id, t => t.Students.ToList());
// Load all students with their teams (without loading team students to avoid circular reference)
var students = await Context.Students var students = await Context.Students
.AsNoTracking() .AsNoTracking()
.Include(s => s.Teams) .Include(s => s.Teams)
@@ -181,6 +192,18 @@
.ThenInclude(t => t.Captain) .ThenInclude(t => t.Captain)
.ToListAsync(); .ToListAsync();
// Populate Students for each team from the separately loaded teams
foreach (var student in students)
{
foreach (var team in student.Teams)
{
if (teamStudentsDict.TryGetValue(team.Id, out var teamStudents))
{
team.Students = teamStudents;
}
}
}
// Filter to only students with teams // Filter to only students with teams
var studentTeams = students var studentTeams = students
.Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent))) .Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent)))
+6 -1
View File
@@ -5,7 +5,12 @@
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized> <NotAuthorized>
@{ @{
navigationManager.NavigateTo("/login", true); var currentUri = navigationManager.Uri;
var baseUri = navigationManager.BaseUri;
var relativePath = currentUri.Replace(baseUri, "").TrimStart('/');
var returnUrl = string.IsNullOrEmpty(relativePath) ? "/" : "/" + relativePath;
var encodedReturnUrl = Uri.EscapeDataString(returnUrl);
navigationManager.NavigateTo($"/login?returnUrl={encodedReturnUrl}", true);
} }
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>
+1 -1
View File
@@ -41,7 +41,7 @@ public class CalendarEventItem : CalendarItem
EventDefinition = occurrence.EventDefinition; EventDefinition = occurrence.EventDefinition;
// Set base class properties that the calendar component uses // Set base class properties that the calendar component uses
StudentFirstNames = studentFirstNames?.ToList() ?? []; StudentFirstNames = studentFirstNames?.ToList() ?? [];
Text = occurrence.EventDefinition?.ShortName; Text = occurrence.EventDefinition?.ShortName ?? string.Empty;
Start = occurrence.StartTime; Start = occurrence.StartTime;
End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1); End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1);