feat: implement core radius service and database schema for guest access management
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Data;
|
||||
|
||||
namespace radiuscontroller.Services;
|
||||
|
||||
public class AdminAuthService
|
||||
{
|
||||
private readonly RadiusDbContext _db;
|
||||
private readonly ProtectedSessionStorage _sessionStorage;
|
||||
|
||||
private const string SessionKey = "AdminUsername";
|
||||
public string? CurrentAdminUsername { get; private set; }
|
||||
public bool IsAuthenticated => !string.IsNullOrEmpty(CurrentAdminUsername);
|
||||
|
||||
public event Action? OnAuthStateChanged;
|
||||
|
||||
public AdminAuthService(RadiusDbContext db, ProtectedSessionStorage sessionStorage)
|
||||
{
|
||||
_db = db;
|
||||
_sessionStorage = sessionStorage;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _sessionStorage.GetAsync<string>(SessionKey);
|
||||
if (result.Success && !string.IsNullOrEmpty(result.Value))
|
||||
{
|
||||
CurrentAdminUsername = result.Value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Session storage might fail during prerendering or early socket connection
|
||||
CurrentAdminUsername = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
var admin = await _db.AdminUsers.FirstOrDefaultAsync(u => u.Username == username.Trim().ToLowerInvariant());
|
||||
if (admin == null)
|
||||
return false;
|
||||
|
||||
bool isValid = BCrypt.Net.BCrypt.Verify(password, admin.PasswordHash);
|
||||
if (isValid)
|
||||
{
|
||||
CurrentAdminUsername = admin.Username;
|
||||
await _sessionStorage.SetAsync(SessionKey, admin.Username);
|
||||
NotifyStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
CurrentAdminUsername = null;
|
||||
try
|
||||
{
|
||||
await _sessionStorage.DeleteAsync(SessionKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore storage errors during logout
|
||||
}
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnAuthStateChanged?.Invoke();
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Data;
|
||||
using radiuscontroller.Models;
|
||||
|
||||
namespace radiuscontroller.Services;
|
||||
|
||||
public interface IRadiusService
|
||||
{
|
||||
Task<SystemConfigDto> GetSystemConfigAsync();
|
||||
Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb);
|
||||
Task<RegistrationResultDto> RegisterGuestAsync(string guestName);
|
||||
Task<List<UserUsageDto>> GetAllUsersWithUsageAsync();
|
||||
Task<List<RadAcct>> GetActiveSessionsAsync();
|
||||
Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb);
|
||||
Task RevokeUserAsync(int profileId);
|
||||
Task DeleteUserAsync(int profileId);
|
||||
Task ResetUserPasswordAsync(int profileId, string newPassword);
|
||||
Task EnsureDatabaseCreatedAndSeededAsync();
|
||||
}
|
||||
|
||||
public class RadiusService : IRadiusService
|
||||
{
|
||||
private readonly RadiusDbContext _db;
|
||||
private readonly ILogger<RadiusService> _logger;
|
||||
|
||||
public RadiusService(RadiusDbContext db, ILogger<RadiusService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SystemConfigDto> GetSystemConfigAsync()
|
||||
{
|
||||
var settings = await _db.SystemSettings.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||
|
||||
int sessionTime = 60; // 60 minutes default
|
||||
if (settings.TryGetValue("default_session_time", out var sessionStr) && int.TryParse(sessionStr, out var parsedSession))
|
||||
{
|
||||
sessionTime = parsedSession;
|
||||
}
|
||||
|
||||
long dataLimit = 500; // 500 MB default
|
||||
if (settings.TryGetValue("default_data_limit", out var dataStr) && long.TryParse(dataStr, out var parsedData))
|
||||
{
|
||||
dataLimit = parsedData;
|
||||
}
|
||||
|
||||
return new SystemConfigDto
|
||||
{
|
||||
DefaultSessionTimeMinutes = sessionTime,
|
||||
DefaultDataLimitMb = dataLimit
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb)
|
||||
{
|
||||
await SetOrUpdateSettingAsync("default_session_time", defaultSessionTimeMinutes.ToString());
|
||||
await SetOrUpdateSettingAsync("default_data_limit", defaultDataLimitMb.ToString());
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SetOrUpdateSettingAsync(string key, string value)
|
||||
{
|
||||
var existing = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = value;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.SystemSettings.Add(new SystemSetting
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RegistrationResultDto> RegisterGuestAsync(string guestName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestName))
|
||||
{
|
||||
throw new ArgumentException("Guest name cannot be empty.", nameof(guestName));
|
||||
}
|
||||
|
||||
var config = await GetSystemConfigAsync();
|
||||
|
||||
// Format clean username base
|
||||
string cleanName = Regex.Replace(guestName.Trim().ToLowerInvariant(), @"[^a-z0-9]", "");
|
||||
if (string.IsNullOrEmpty(cleanName)) cleanName = "guest";
|
||||
if (cleanName.Length > 12) cleanName = cleanName[..12];
|
||||
|
||||
string username = $"{cleanName}_{RandomNumberGenerator.GetInt32(1000, 9999)}";
|
||||
string password = GenerateReadablePassword(8);
|
||||
|
||||
// Save Cleartext-Password for FreeRADIUS
|
||||
_db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Cleartext-Password",
|
||||
Op = ":=",
|
||||
Value = password
|
||||
});
|
||||
|
||||
// Set Session-Timeout (in seconds)
|
||||
long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60;
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Session-Timeout",
|
||||
Op = "=",
|
||||
Value = sessionTimeoutSeconds.ToString()
|
||||
});
|
||||
|
||||
// Set Max-Octets (in bytes)
|
||||
long maxOctetsBytes = config.DefaultDataLimitMb * 1024 * 1024;
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Max-Octets",
|
||||
Op = "=",
|
||||
Value = maxOctetsBytes.ToString()
|
||||
});
|
||||
|
||||
// Create Guest Profile record
|
||||
var profile = new GuestProfile
|
||||
{
|
||||
GuestName = guestName.Trim(),
|
||||
Username = username,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SessionTimeMinutes = config.DefaultSessionTimeMinutes,
|
||||
DataLimitMb = config.DefaultDataLimitMb,
|
||||
Status = "Active"
|
||||
};
|
||||
_db.GuestProfiles.Add(profile);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new RegistrationResultDto
|
||||
{
|
||||
GuestName = profile.GuestName,
|
||||
Username = username,
|
||||
Password = password,
|
||||
SessionTimeMinutes = config.DefaultSessionTimeMinutes,
|
||||
DataLimitMb = config.DefaultDataLimitMb,
|
||||
ExpirationEstimate = DateTime.UtcNow.AddMinutes(config.DefaultSessionTimeMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageDto>> GetAllUsersWithUsageAsync()
|
||||
{
|
||||
var profiles = await _db.GuestProfiles.OrderByDescending(p => p.CreatedAt).ToListAsync();
|
||||
var passwords = await _db.RadCheck
|
||||
.Where(rc => rc.Attribute == "Cleartext-Password")
|
||||
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
||||
|
||||
// Group accounting data by username
|
||||
var acctStats = await _db.RadAcct
|
||||
.GroupBy(ra => ra.Username)
|
||||
.Select(g => new
|
||||
{
|
||||
Username = g.Key,
|
||||
TotalInput = g.Sum(x => x.AcctInputOctets ?? 0),
|
||||
TotalOutput = g.Sum(x => x.AcctOutputOctets ?? 0),
|
||||
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
||||
IsConnected = g.Any(x => x.AcctStopTime == null)
|
||||
})
|
||||
.ToDictionaryAsync(g => g.Username);
|
||||
|
||||
var result = new List<UserUsageDto>();
|
||||
|
||||
foreach (var p in profiles)
|
||||
{
|
||||
passwords.TryGetValue(p.Username, out var pwd);
|
||||
acctStats.TryGetValue(p.Username, out var acct);
|
||||
|
||||
result.Add(new UserUsageDto
|
||||
{
|
||||
ProfileId = p.Id,
|
||||
GuestName = p.GuestName,
|
||||
Username = p.Username,
|
||||
Password = pwd ?? "••••••••",
|
||||
CreatedAt = p.CreatedAt,
|
||||
SessionTimeMinutes = p.SessionTimeMinutes,
|
||||
DataLimitMb = p.DataLimitMb,
|
||||
Status = p.Status,
|
||||
TotalInputOctets = acct?.TotalInput ?? 0,
|
||||
TotalOutputOctets = acct?.TotalOutput ?? 0,
|
||||
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
||||
IsCurrentlyConnected = acct?.IsConnected ?? false
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<RadAcct>> GetActiveSessionsAsync()
|
||||
{
|
||||
return await _db.RadAcct
|
||||
.Where(ra => ra.AcctStopTime == null)
|
||||
.OrderByDescending(ra => ra.AcctStartTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
profile.SessionTimeMinutes = newSessionTimeMinutes;
|
||||
profile.DataLimitMb = newDataLimitMb;
|
||||
|
||||
// Update RadReply Session-Timeout
|
||||
var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout");
|
||||
if (sessionReply != null)
|
||||
{
|
||||
sessionReply.Value = (newSessionTimeMinutes * 60).ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Session-Timeout",
|
||||
Op = "=",
|
||||
Value = (newSessionTimeMinutes * 60).ToString()
|
||||
});
|
||||
}
|
||||
|
||||
// Update RadReply Max-Octets
|
||||
var dataReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Max-Octets");
|
||||
long maxOctetsBytes = newDataLimitMb * 1024 * 1024;
|
||||
if (dataReply != null)
|
||||
{
|
||||
dataReply.Value = maxOctetsBytes.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Max-Octets",
|
||||
Op = "=",
|
||||
Value = maxOctetsBytes.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RevokeUserAsync(int profileId)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
profile.Status = "Revoked";
|
||||
|
||||
// Remove Cleartext-Password and insert Auth-Type := Reject
|
||||
var checks = await _db.RadCheck.Where(rc => rc.Username == profile.Username).ToListAsync();
|
||||
_db.RadCheck.RemoveRange(checks);
|
||||
|
||||
_db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Auth-Type",
|
||||
Op = ":=",
|
||||
Value = "Reject"
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int profileId)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
var username = profile.Username;
|
||||
|
||||
var checks = await _db.RadCheck.Where(rc => rc.Username == username).ToListAsync();
|
||||
var replies = await _db.RadReply.Where(rr => rr.Username == username).ToListAsync();
|
||||
|
||||
_db.RadCheck.RemoveRange(checks);
|
||||
_db.RadReply.RemoveRange(replies);
|
||||
_db.GuestProfiles.Remove(profile);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ResetUserPasswordAsync(int profileId, string newPassword)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
var check = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Cleartext-Password");
|
||||
if (check != null)
|
||||
{
|
||||
check.Value = newPassword;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Cleartext-Password",
|
||||
Op = ":=",
|
||||
Value = newPassword
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.Status == "Revoked")
|
||||
{
|
||||
profile.Status = "Active";
|
||||
var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type");
|
||||
if (rejectCheck != null)
|
||||
{
|
||||
_db.RadCheck.Remove(rejectCheck);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task EnsureDatabaseCreatedAndSeededAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.Database.EnsureCreatedAsync();
|
||||
|
||||
if (!await _db.AdminUsers.AnyAsync())
|
||||
{
|
||||
// Default admin user: admin / admin123
|
||||
string hash = BCrypt.Net.BCrypt.HashPassword("admin123");
|
||||
_db.AdminUsers.Add(new AdminUser
|
||||
{
|
||||
Username = "admin",
|
||||
PasswordHash = hash,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (!await _db.SystemSettings.AnyAsync())
|
||||
{
|
||||
_db.SystemSettings.Add(new SystemSetting { Key = "default_session_time", Value = "60", UpdatedAt = DateTime.UtcNow });
|
||||
_db.SystemSettings.Add(new SystemSetting { Key = "default_data_limit", Value = "500", UpdatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Database initialization warning (Database might be populated via Docker init.sql script).");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateReadablePassword(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
var result = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user