Skip to main content

Authentication & User Management

The authentication subsystem handles the complete user lifecycle — registration, email verification, login, session management, and administrative user control.


Registration Flow

Registration Implementation

public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
{
var user = new ApplicationUser
{
UserName = request.Email,
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
CreatedAt = DateTime.UtcNow
};

var result = await _userManager.CreateAsync(user, request.Password);

if (result.Succeeded)
{
// Assign Overseer role if email matches configured overseer email
var overseerEmail = _configuration[ConfigKeys.AuthOverseerEmail];
var roleToAssign = !string.IsNullOrEmpty(overseerEmail) &&
user.Email?.Equals(overseerEmail, StringComparison.OrdinalIgnoreCase) == true
? Roles.Overseer : Roles.User;

await _userManager.AddToRoleAsync(user, roleToAssign);

// Generate cryptographically secure 6-digit verification code
var code = RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
user.EmailVerificationCode = code;
user.EmailVerificationCodeExpiry = DateTime.UtcNow.AddMinutes(10);
await _userManager.UpdateAsync(user);

// Create default user settings
await _userSettingService.UpsertSettingAsync(
user.Id, (int)SettingType.AutoUpdateWeatherData, "0");

await _emailService.SendVerificationCodeAsync(
user.Email!, code, user.FirstName ?? "User");
}
// ...
}

Default User Settings

On registration, two settings are automatically created:

SettingDefault ValueEffect
AutoUpdateWeatherData1 (enabled)Weather data auto-refreshed every 6 hours
AutoRunPredictions1 (enabled)ML predictions auto-run every 6 hours

Overseer Assignment

The first user to register with the email configured in Auth:OverseerEmail (appsettings) is automatically assigned the Overseer role. All other users receive the User role.


Email Verification Flow

Rate Limiting

ProtectionThresholdCooldown
Code resend1 request per 60 secondsAutomatic (checked via LastCodeSentAt)
Verification attempts5 failures15-minute lockout (VerificationLockoutUntil)

Verification Implementation

public async Task<AuthResponse> VerifyEmailAsync(VerifyEmailRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);

// Check lockout (15-minute window after 5 failures)
if (user.VerificationLockoutUntil.HasValue &&
user.VerificationLockoutUntil > DateTime.UtcNow)
return new AuthResponse { Success = false, Message = "Too many failed attempts." };

// Check expiry (10-minute window)
if (user.EmailVerificationCodeExpiry < DateTime.UtcNow)
return new AuthResponse { Success = false, Message = "Verification code has expired." };

if (user.EmailVerificationCode != request.Code)
{
user.FailedVerificationAttempts++;
if (user.FailedVerificationAttempts >= 5)
{
user.VerificationLockoutUntil = DateTime.UtcNow.AddMinutes(15);
user.FailedVerificationAttempts = 0;
}
await _userManager.UpdateAsync(user);
return new AuthResponse { Success = false, Message = "Invalid verification code" };
}

// Success — confirm, sign in, send welcome email
user.IsEmailVerified = true;
user.EmailVerificationCode = null;
user.EmailVerificationCodeExpiry = null;
user.FailedVerificationAttempts = 0;
user.VerificationLockoutUntil = null;
await _userManager.UpdateAsync(user);

await _signInManager.SignInAsync(user, isPersistent: false);
await _emailService.SendWelcomeEmailAsync(user.Email!, user.FirstName ?? "User");
// ...
}

Resend Code Implementation

The resend endpoint enforces a 60-second cooldown between requests per user:

public async Task<AuthResponse> ResendVerificationCodeAsync(ResendCodeRequest request)
{
if (user.LastCodeSentAt.HasValue &&
DateTime.UtcNow - user.LastCodeSentAt.Value < TimeSpan.FromSeconds(60))
return new AuthResponse { Success = false, Message = "Please wait before requesting a new code" };

var code = RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
user.EmailVerificationCode = code;
user.EmailVerificationCodeExpiry = DateTime.UtcNow.AddMinutes(10);
user.LastCodeSentAt = DateTime.UtcNow;
await _userManager.UpdateAsync(user);

await _emailService.SendVerificationCodeAsync(user.Email!, code, user.FirstName ?? "User");
// ...
}

Login Flow

Session Configuration

PropertyValue
Cookie name.AspNetCore.Identity.Application
HttpOnlytrue
SecureAlways (HTTPS only)
SameSiteLax
Expiration7 days (sliding)

Login Implementation

public async Task<AuthResponse> LoginAsync(LoginRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user == null)
return new AuthResponse { Success = false, Message = "Invalid email or password" };

if (!user.IsEmailVerified)
return new AuthResponse { Success = false, Message = "Please verify your email first" };

var result = await _signInManager.PasswordSignInAsync(
user.UserName!, request.Password, request.RememberMe, lockoutOnFailure: true);

if (result.Succeeded)
{
var roles = await _userManager.GetRolesAsync(user);
return new AuthResponse
{
Success = true,
User = new UserDto { Id = user.Id, Email = user.Email, Roles = roles.ToList() }
};
}

if (result.IsLockedOut)
return new AuthResponse { Success = false, Message = "Account locked. Please try again later." };

return new AuthResponse { Success = false, Message = "Invalid email or password" };
}
services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(7);

// Return JSON 401/403 instead of redirecting to login page
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = 403;
return Task.CompletedTask;
};
});

Administrative User Management

Administrators (Admin and Overseer roles) can manage users through the Admin API:

Available Operations

OperationEndpointRestrictions
List all usersGET /api/admin/usersPaginated, searchable
Update userPUT /api/admin/users/{id}Role assignment, name changes
Confirm emailPOST /api/admin/users/{id}/confirm-emailBypass verification
Delete userDELETE /api/admin/users/{id}Protected accounts

Role Assignment Rules

Caller RoleCan AssignCannot Assign
OverseerOverseer, Admin, User
AdminAdmin, UserOverseer

Protected Operations

RuleDescription
No self-modificationA user cannot change their own roles or delete themselves
Overseer protectionOverseer accounts cannot be deleted by any user
Email confirmationAdmins can manually confirm a user's email (bypassing code)

Admin Service Implementation

public async Task<UserResponse> UpdateUserAsync(string id, UpdateUserRequest request, string callerUserId)
{
// Safety guard: cannot modify own account
if (id == callerUserId)
return new UserResponse { Success = false,
Message = "Cannot modify your own account through admin panel" };

// Only Overseer can assign Overseer role
var isCallerOverseer = await IsUserInRole(callerUserId, Roles.Overseer);
if (request.Roles.Contains(Roles.Overseer) && !isCallerOverseer)
return new UserResponse { Success = false,
Message = "Only an Overseer can assign the Overseer role" };

// Atomic role diff with rollback on failure
var currentRoles = await _userManager.GetRolesAsync(user);
var rolesToRemove = currentRoles.Except(request.Roles).ToList();
var rolesToAdd = request.Roles.Except(currentRoles).ToList();

if (rolesToRemove.Any())
await _userManager.RemoveFromRolesAsync(user, rolesToRemove);

if (rolesToAdd.Any())
{
var addResult = await _userManager.AddToRolesAsync(user, rolesToAdd);
if (!addResult.Succeeded)
await _userManager.AddToRolesAsync(user, rolesToRemove); // Rollback
}
// ...
}

public async Task<UserResponse> DeleteUserAsync(string id, string callerUserId)
{
// Cannot delete Overseer accounts
var roles = await _userManager.GetRolesAsync(user);
if (roles.Contains(Roles.Overseer))
return new UserResponse { Success = false,
Message = "Cannot delete the Overseer account." };

await _userManager.DeleteAsync(user);
// ...
}

User Settings Service

The UserSettingService provides a simple upsert-based key-value store for user preferences:

MethodBehavior
GetSettingAsync(userId, settingType)Returns the setting value or null if not set
UpsertSettingAsync(userId, settingType, value)Creates or updates the setting; returns the entity

Implementation

public async Task<UserSetting> UpsertSettingAsync(string userId, int settingType, string value)
{
var existing = await _context.UserSettings
.FirstOrDefaultAsync(s => s.UserId == userId && s.SettingType == settingType);

if (existing != null)
{
existing.Value = value;
existing.UpdatedAt = DateTime.UtcNow;
}
else
{
_context.UserSettings.Add(new UserSetting
{
UserId = userId,
SettingType = settingType,
Value = value,
CreatedAt = DateTime.UtcNow
});
}

await _context.SaveChangesAsync();
return existing ?? /* new entry */;
}

Setting Types

TypeValueEffect
AutoUpdateWeatherData"0" or "1"Opt in/out of Quartz weather refresh job
AutoRunPredictions"0" or "1"Opt in/out of Quartz prediction job
TemperatureUnit"celsius" or "fahrenheit"Display preference in UI

Settings are stored as string values and interpreted by the consuming services according to the setting type's expected format.


Identity Configuration

services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;

options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;

options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();