diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Database/UserDatabaseContext.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Database/UserDatabaseContext.cs new file mode 100644 index 0000000..b3a6730 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Database/UserDatabaseContext.cs @@ -0,0 +1,14 @@ +using Elektrifikatsiya.Models; + +using Microsoft.EntityFrameworkCore; + +namespace Elektrifikatsiya.Database; + +public class UserDatabaseContext : DbContext +{ + public UserDatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + { + } + + public DbSet Users { get; set; } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj b/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj index d7b4d83..2912297 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj @@ -23,11 +23,15 @@ + + + + diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/Role.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Role.cs new file mode 100644 index 0000000..08e4fb4 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Role.cs @@ -0,0 +1,9 @@ +namespace Elektrifikatsiya.Models; + +// Higher up = Higher permission +// Or you can set the priority manually = +public enum Role +{ + Admin, + User +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/User.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/User.cs new file mode 100644 index 0000000..175c25d --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/User.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Elektrifikatsiya.Models; + +public class User +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Key] + public int Id { get; private set; } + + public string Name { get; private set; } + public string PasswordHash { get; private set; } + public string? SessionToken { get; set; } + public DateTime LastLoginDate { get; set; } + public Role Role { get; set; } + + public User(string name, string passwordHash, Role role) + { + Name = name; + PasswordHash = passwordHash; + Role = role; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml index 3cc3b8d..45c56e9 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml @@ -41,5 +41,34 @@ + + diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs index 210cf3c..30a299e 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs @@ -3,11 +3,16 @@ using Blazorise.Bootstrap; using Blazorise.Icons.Material; using Blazorise.Material; +using Elektrifikatsiya.Database; + +using Microsoft.EntityFrameworkCore; + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=./UserDatabase.sqlite")); AddBlazorise(builder.Services); @@ -26,12 +31,11 @@ app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); +app.MapBlazorHub(); +app.MapFallbackToPage("/_Host"); -app.UseEndpoints(endpoints => -{ - _ = endpoints.MapBlazorHub(); - _ = endpoints.MapFallbackToPage("/_Host"); -}); +IServiceScope serviceScope = app.Services.GetRequiredService().CreateScope(); +serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); app.Run(); diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthenticationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthenticationService.cs new file mode 100644 index 0000000..2ccc8ec --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthenticationService.cs @@ -0,0 +1,55 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services; + +public interface IAuthenticationService +{ + /// + /// Gets the current user. + /// + /// to and the currently authenticated user. + Task> GetUserAsync(); + + /// + /// Registers a new user. + /// + /// The E-mail address of the user. + /// A to . + + Task RegisterUserAsync(string name, string password, Role role); + + /// + /// Logs a user in. + /// + /// The E-mail address of the user. + /// A to . + Task LoginUserAsync(string name, string password); + + /// + /// Logs the user out. + /// + /// A to . + Task LogoutUserAsync(); + + /// + /// Deletes the current user. + /// + /// The deletion token. + /// A to and a that indicates if the operation was successful. + Task DeleteUserAsync(); + + /// + /// Checks if a user exists. + /// + /// The E-mail address of the user. + /// A to and a that indicates if the user exists. + Task> UserExistsAsync(string name); + + /// + /// Check if the current user is authenticated. + /// + /// A to and a that indicates if the user is authenticated. + Task> IsAuthenticated(); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthorizationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthorizationService.cs new file mode 100644 index 0000000..30be3d0 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthorizationService.cs @@ -0,0 +1,15 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services; + +public interface IAuthorizationService +{ + /// + /// Check if the current user is authorized. + /// + /// The minimum required role. + /// A to and a that indicates if the user is authorized. + Task> IsAuthorized(Role requiredRole); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/ICookieService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/ICookieService.cs new file mode 100644 index 0000000..a954ea8 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/ICookieService.cs @@ -0,0 +1,13 @@ +namespace Elektrifikatsiya.Services; + +public interface ICookieService +{ + /// + /// Writes a cookie. + /// + /// The name of the cookie. + /// The value of the cookie. + /// Indicates the maximum lifetime of the cookie. + /// + Task WriteCookieAsync(string name, string value, int days); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthenticationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthenticationService.cs new file mode 100644 index 0000000..36ce68e --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthenticationService.cs @@ -0,0 +1,134 @@ +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Utilities; + +using FluentResults; + +using Microsoft.EntityFrameworkCore; + +using BC = BCrypt.Net.BCrypt; + +namespace Elektrifikatsiya.Services.Implementations; + +public class AuthenticationService : IAuthenticationService +{ + private readonly UserDatabaseContext userDatabaseContext; + private readonly IHttpContextAccessor httpContextAccessor; + private readonly ICookieService cookieService; + + public AuthenticationService(UserDatabaseContext userDatabaseContext, IHttpContextAccessor httpContextAccessor, ICookieService cookieService) + { + this.userDatabaseContext = userDatabaseContext; + this.httpContextAccessor = httpContextAccessor; + this.cookieService = cookieService; + } + + public async Task DeleteUserAsync() + { + Result getUserResult = await GetUserAsync(); + + if (getUserResult.IsFailed) + { + return getUserResult.ToResult(); + } + + _ = userDatabaseContext.Users.Remove(getUserResult.Value); + return (await Result.Try(() => userDatabaseContext.SaveChangesAsync())).ToResult(); + } + + public async Task> GetUserAsync() + { + string? token = httpContextAccessor.HttpContext?.Request.Cookies["token"]?.ToString(); + + if (!TokenGenerator.ValidateToken(token, "auth")) + { + return Result.Fail("Token is not valid!"); + } + + User? user = await userDatabaseContext.Users.FirstOrDefaultAsync(u => u.SessionToken == token); + + if (user is null || DateTime.UtcNow - user.LastLoginDate > TimeSpan.FromDays(7)) + { + return Result.Fail("Token is not valid or expired!"); + } + + return user; + } + + public async Task> IsAuthenticated() + { + return (await GetUserAsync()).IsSuccess; + } + + public async Task LoginUserAsync(string name, string password) + { + User? user = await userDatabaseContext.Users.FirstOrDefaultAsync(u => u.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); + + if (user is null) + { + return Result.Fail("User does not exist!"); + } + + if (!BC.Verify(password, user.PasswordHash)) + { + return Result.Fail("Invalid credentials!"); + } + + string newToken = TokenGenerator.GenerateToken("auth", user.Id); + + user.LastLoginDate = DateTime.UtcNow; + user.SessionToken = newToken; + + _ = await userDatabaseContext.SaveChangesAsync(); + + await cookieService.WriteCookieAsync("token", newToken, 7); + + return Result.Ok(); + } + + public async Task LogoutUserAsync() + { + string? token = httpContextAccessor.HttpContext?.Request.Cookies["token"]?.ToString(); + + await cookieService.WriteCookieAsync("token", string.Empty, 0); + + if (!TokenGenerator.ValidateToken(token, "auth")) + { + return Result.Ok(); + } + + User? user = await userDatabaseContext.Users.FirstOrDefaultAsync(u => u.SessionToken == token); + + if (user is null) + { + return Result.Ok(); + } + + user.SessionToken = null; + + _ = await userDatabaseContext.SaveChangesAsync(); + + return Result.Ok(); + } + + public async Task RegisterUserAsync(string name, string password, Role role) + { + Result userExistsResult = await UserExistsAsync(name); + + if (userExistsResult.IsFailed || userExistsResult.Value) + { + return Result.Fail("User name already exists!"); + } + + User user = new User(name, BC.HashPassword(password), role); + _ = userDatabaseContext.Users.Add(user); + + return (await Result.Try(() => userDatabaseContext.SaveChangesAsync())).ToResult(); + } + + public Task> UserExistsAsync(string name) + { + return Result.Try(() => + userDatabaseContext.Users.AnyAsync(u => u.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthorizationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthorizationService.cs new file mode 100644 index 0000000..e62b137 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthorizationService.cs @@ -0,0 +1,29 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services.Implementations; + +public class AuthorizationService : IAuthorizationService +{ + private readonly IAuthenticationService authenticationService; + + public AuthorizationService(IAuthenticationService authenticationService) + { + this.authenticationService = authenticationService; + } + + public async Task> IsAuthorized(Role requiredRole) + { + Result userResult = await authenticationService.GetUserAsync(); + + if (userResult.IsSuccess && userResult.Value.Role <= requiredRole) + { + return Result.Ok(true); + } + else + { + return Result.Fail("User doesn't have the required role!"); + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/CookieService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/CookieService.cs new file mode 100644 index 0000000..d654fdc --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/CookieService.cs @@ -0,0 +1,18 @@ +using Microsoft.JSInterop; + +namespace Elektrifikatsiya.Services.Implementations; + +public class CookieService : ICookieService +{ + private readonly IJSRuntime jsRuntime; + + public CookieService(IJSRuntime jsRuntime) + { + this.jsRuntime = jsRuntime; + } + + public async Task WriteCookieAsync(string name, string value, int days) + { + _ = await jsRuntime.InvokeAsync("blazorExtensions.WriteCookie", name, value, days); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/SecureStringGenerator.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/SecureStringGenerator.cs new file mode 100644 index 0000000..a37cb29 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/SecureStringGenerator.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; + +namespace Elektrifikatsiya.Utilities; + +public static class SecureStringGenerator +{ + public static string CreateCryptographicRandomString(int count) + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(count)); + } + + public static string CreateCryptographicRandomString(int count, int uid) + { + List bytes = RandomNumberGenerator.GetBytes(count).ToList(); + + bytes.AddRange(BitConverter.GetBytes(uid)); + + return Convert.ToBase64String(bytes.ToArray()); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/TokenGenerator.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/TokenGenerator.cs new file mode 100644 index 0000000..99faf60 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/TokenGenerator.cs @@ -0,0 +1,19 @@ +namespace Elektrifikatsiya.Utilities; + +public static class TokenGenerator +{ + public static string GenerateToken(string tokenType, int uid = 0, int length = 128) + { + return tokenType + "-" + SecureStringGenerator.CreateCryptographicRandomString(length, uid); + } + + public static bool ValidateToken(string? token, string tokenType) + { + if (token is null) + { + return false; + } + + return token.StartsWith(tokenType + "-"); + } +} \ No newline at end of file