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:
| Setting | Default Value | Effect |
|---|---|---|
AutoUpdateWeatherData | 1 (enabled) | Weather data auto-refreshed every 6 hours |
AutoRunPredictions | 1 (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
| Protection | Threshold | Cooldown |
|---|---|---|
| Code resend | 1 request per 60 seconds | Automatic (checked via LastCodeSentAt) |
| Verification attempts | 5 failures | 15-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
| Property | Value |
|---|---|
| Cookie name | .AspNetCore.Identity.Application |
| HttpOnly | true |
| Secure | Always (HTTPS only) |
| SameSite | Lax |
| Expiration | 7 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" };
}
Cookie Configuration
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
| Operation | Endpoint | Restrictions |
|---|---|---|
| List all users | GET /api/admin/users | Paginated, searchable |
| Update user | PUT /api/admin/users/{id} | Role assignment, name changes |
| Confirm email | POST /api/admin/users/{id}/confirm-email | Bypass verification |
| Delete user | DELETE /api/admin/users/{id} | Protected accounts |
Role Assignment Rules
| Caller Role | Can Assign | Cannot Assign |
|---|---|---|
| Overseer | Overseer, Admin, User | — |
| Admin | Admin, User | Overseer |
Protected Operations
| Rule | Description |
|---|---|
| No self-modification | A user cannot change their own roles or delete themselves |
| Overseer protection | Overseer accounts cannot be deleted by any user |
| Email confirmation | Admins 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:
| Method | Behavior |
|---|---|
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
| Type | Value | Effect |
|---|---|---|
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();