From 8fda2e9907613c3ee2f97b5af9346a502e537906 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Tue, 20 Feb 2024 20:39:52 +0000 Subject: [PATCH] feat: add blog admin page and simple login --- .../Controllers/Blog/AdminController.cs | 78 +++++++++++ OliverBooth/Data/Blog/BlogContext.cs | 7 + .../Configuration/SessionConfiguration.cs | 24 ++++ .../Blog/Configuration/UserConfiguration.cs | 1 + OliverBooth/Data/Blog/ISession.cs | 84 ++++++++++++ OliverBooth/Data/Blog/IUser.cs | 6 + OliverBooth/Data/Blog/User.cs | 3 + OliverBooth/Pages/Blog/Admin/Index.cshtml | 9 ++ OliverBooth/Pages/Blog/Admin/Index.cshtml.cs | 81 ++++++++++++ OliverBooth/Pages/Blog/Admin/Login.cshtml | 22 ++++ OliverBooth/Pages/Blog/Admin/Login.cshtml.cs | 31 +++++ OliverBooth/Program.cs | 1 + OliverBooth/Services/BlogUserService.cs | 9 ++ OliverBooth/Services/IBlogUserService.cs | 14 ++ OliverBooth/Services/ISessionService.cs | 56 ++++++++ OliverBooth/Services/SessionService.cs | 123 ++++++++++++++++++ 16 files changed, 549 insertions(+) create mode 100644 OliverBooth/Controllers/Blog/AdminController.cs create mode 100644 OliverBooth/Data/Blog/Configuration/SessionConfiguration.cs create mode 100644 OliverBooth/Data/Blog/ISession.cs create mode 100644 OliverBooth/Pages/Blog/Admin/Index.cshtml create mode 100644 OliverBooth/Pages/Blog/Admin/Index.cshtml.cs create mode 100644 OliverBooth/Pages/Blog/Admin/Login.cshtml create mode 100644 OliverBooth/Pages/Blog/Admin/Login.cshtml.cs create mode 100644 OliverBooth/Services/ISessionService.cs create mode 100644 OliverBooth/Services/SessionService.cs diff --git a/OliverBooth/Controllers/Blog/AdminController.cs b/OliverBooth/Controllers/Blog/AdminController.cs new file mode 100644 index 0000000..27b7757 --- /dev/null +++ b/OliverBooth/Controllers/Blog/AdminController.cs @@ -0,0 +1,78 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using OliverBooth.Data.Blog; +using OliverBooth.Services; +using ISession = OliverBooth.Data.Blog.ISession; + +namespace OliverBooth.Controllers.Blog; + +[Controller] +[Route("auth/admin")] +public sealed class AdminController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IBlogUserService _userService; + private readonly ISessionService _sessionService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The user service. + /// The session service. + public AdminController(ILogger logger, + IBlogUserService userService, + ISessionService sessionService) + { + _logger = logger; + _userService = userService; + _sessionService = sessionService; + } + + [HttpPost("login")] + public IActionResult Login() + { + string? loginEmail = Request.Form["login-email"]; + string? loginPassword = Request.Form["login-password"]; + IPAddress? remoteIpAddress = Request.HttpContext.Connection.RemoteIpAddress; + + if (string.IsNullOrWhiteSpace(loginEmail)) + { + _logger.LogInformation("Login attempt from {Host} with empty login", remoteIpAddress); + return RedirectToPage("/blog/admin/login"); + } + + if (string.IsNullOrWhiteSpace(loginPassword)) + { + _logger.LogInformation("Login attempt as '{Email}' from {Host} with empty password", loginEmail, + remoteIpAddress); + return RedirectToPage("/blog/admin/login"); + } + + if (_userService.VerifyLogin(loginEmail, loginPassword, out IUser? user)) + { + _logger.LogInformation("Login attempt for '{Email}' succeeded from {Host}", loginEmail, remoteIpAddress); + } + else + { + _logger.LogInformation("Login attempt for '{Email}' failed from {Host}", loginEmail, remoteIpAddress); + return RedirectToPage("/blog/admin/login"); + } + + ISession session = _sessionService.CreateSession(Request, user); + Span sessionBytes = stackalloc byte[16]; + session.Id.TryWriteBytes(sessionBytes); + Response.Cookies.Append("sid", Convert.ToBase64String(sessionBytes)); + return RedirectToPage("/blog/admin/index"); + } + + [HttpGet("logout")] + public IActionResult Logout() + { + if (_sessionService.TryGetSession(Request, out ISession? session, true)) + _sessionService.DeleteSession(session); + + Response.Cookies.Delete("sid"); + return RedirectToPage("/blog/admin/login"); + } +} diff --git a/OliverBooth/Data/Blog/BlogContext.cs b/OliverBooth/Data/Blog/BlogContext.cs index ed9f56f..3f37b46 100644 --- a/OliverBooth/Data/Blog/BlogContext.cs +++ b/OliverBooth/Data/Blog/BlogContext.cs @@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext /// The collection of blog posts. public DbSet BlogPosts { get; private set; } = null!; + /// + /// Gets the collection of sessions in the database. + /// + /// The collection of sessions. + public DbSet Sessions { get; private set; } = null!; + /// /// Gets the collection of users in the database. /// @@ -43,6 +49,7 @@ internal sealed class BlogContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); + modelBuilder.ApplyConfiguration(new SessionConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration()); } } diff --git a/OliverBooth/Data/Blog/Configuration/SessionConfiguration.cs b/OliverBooth/Data/Blog/Configuration/SessionConfiguration.cs new file mode 100644 index 0000000..cfb5079 --- /dev/null +++ b/OliverBooth/Data/Blog/Configuration/SessionConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace OliverBooth.Data.Blog.Configuration; + +internal sealed class SessionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Session"); + builder.HasKey(e => e.Id); + + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.Created).IsRequired(); + builder.Property(e => e.Updated).IsRequired(); + builder.Property(e => e.LastAccessed).IsRequired(); + builder.Property(e => e.Expires).IsRequired(); + builder.Property(e => e.UserId); + builder.Property(e => e.IpAddress).HasConversion().IsRequired(); + builder.Property(e => e.RequiresTotp).IsRequired(); + } +} diff --git a/OliverBooth/Data/Blog/Configuration/UserConfiguration.cs b/OliverBooth/Data/Blog/Configuration/UserConfiguration.cs index 65060d4..7c16adf 100644 --- a/OliverBooth/Data/Blog/Configuration/UserConfiguration.cs +++ b/OliverBooth/Data/Blog/Configuration/UserConfiguration.cs @@ -17,5 +17,6 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration builder.Property(e => e.Password).HasMaxLength(255).IsRequired(); builder.Property(e => e.Salt).HasMaxLength(255).IsRequired(); builder.Property(e => e.Registered).IsRequired(); + builder.Property(e => e.Totp); } } diff --git a/OliverBooth/Data/Blog/ISession.cs b/OliverBooth/Data/Blog/ISession.cs new file mode 100644 index 0000000..f54164f --- /dev/null +++ b/OliverBooth/Data/Blog/ISession.cs @@ -0,0 +1,84 @@ +using System.Net; + +namespace OliverBooth.Data.Blog; + +/// +/// Represents a login session. +/// +public interface ISession +{ + /// + /// Gets the date and time at which this session was created. + /// + /// The creation timestamp. + DateTimeOffset Created { get; } + + /// + /// Gets the date and time at which this session expires. + /// + /// The expiration timestamp. + DateTimeOffset Expires { get; } + + /// + /// Gets the ID of the session. + /// + /// The ID of the session. + Guid Id { get; } + + /// + /// Gets the IP address of the session. + /// + /// The IP address. + IPAddress IpAddress { get; } + + /// + /// Gets the date and time at which this session was last accessed. + /// + /// The last access timestamp. + DateTimeOffset LastAccessed { get; } + + /// + /// Gets a value indicating whether this session is valid. + /// + /// if the session is valid; otherwise, . + bool RequiresTotp { get; } + + /// + /// Gets the date and time at which this session was updated. + /// + /// The update timestamp. + DateTimeOffset Updated { get; } + + /// + /// Gets the user ID associated with the session. + /// + /// The user ID. + Guid UserId { get; } +} + +internal sealed class Session : ISession +{ + /// + public DateTimeOffset Created { get; set; } + + /// + public DateTimeOffset Expires { get; set; } + + /// + public Guid Id { get; private set; } = Guid.NewGuid(); + + /// + public IPAddress IpAddress { get; set; } = IPAddress.None; + + /// + public DateTimeOffset LastAccessed { get; set; } + + /// + public bool RequiresTotp { get; set; } + + /// + public DateTimeOffset Updated { get; set; } + + /// + public Guid UserId { get; set; } +} diff --git a/OliverBooth/Data/Blog/IUser.cs b/OliverBooth/Data/Blog/IUser.cs index 912db14..6fdedd6 100644 --- a/OliverBooth/Data/Blog/IUser.cs +++ b/OliverBooth/Data/Blog/IUser.cs @@ -35,6 +35,12 @@ public interface IUser /// The registration date and time. DateTimeOffset Registered { get; } + /// + /// Gets the user's TOTP token. + /// + /// The TOTP token. + string? Totp { get; } + /// /// Gets the URL of the user's avatar. /// diff --git a/OliverBooth/Data/Blog/User.cs b/OliverBooth/Data/Blog/User.cs index dcd50ab..987e2e7 100644 --- a/OliverBooth/Data/Blog/User.cs +++ b/OliverBooth/Data/Blog/User.cs @@ -26,6 +26,9 @@ internal sealed class User : IUser, IBlogAuthor /// public DateTimeOffset Registered { get; private set; } = DateTimeOffset.UtcNow; + /// + public string? Totp { get; private set; } + /// /// Gets or sets the password hash. /// diff --git a/OliverBooth/Pages/Blog/Admin/Index.cshtml b/OliverBooth/Pages/Blog/Admin/Index.cshtml new file mode 100644 index 0000000..a34a35a --- /dev/null +++ b/OliverBooth/Pages/Blog/Admin/Index.cshtml @@ -0,0 +1,9 @@ +@page +@model OliverBooth.Pages.Blog.Admin.Index + +@{ + ViewData["Title"] = "Admin"; +} + +

Hello @(Model.CurrentUser.DisplayName)!

+Logout \ No newline at end of file diff --git a/OliverBooth/Pages/Blog/Admin/Index.cshtml.cs b/OliverBooth/Pages/Blog/Admin/Index.cshtml.cs new file mode 100644 index 0000000..144fd15 --- /dev/null +++ b/OliverBooth/Pages/Blog/Admin/Index.cshtml.cs @@ -0,0 +1,81 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using OliverBooth.Data.Blog; +using OliverBooth.Services; +using ISession = OliverBooth.Data.Blog.ISession; + +namespace OliverBooth.Pages.Blog.Admin; + +public class Index : PageModel +{ + private readonly IBlogUserService _userService; + private readonly ISessionService _sessionService; + + public Index(IBlogUserService userService, ISessionService sessionService) + { + _userService = userService; + _sessionService = sessionService; + } + + public IUser CurrentUser { get; private set; } = null!; + + public IActionResult OnGet() + { + IPAddress? remoteIpAddress = Request.HttpContext.Connection.RemoteIpAddress; + if (remoteIpAddress is null) + { + return RedirectToPage("login"); + } + + if (!Request.Cookies.TryGetValue("sid", out string? sessionIdCookie)) + { + return RedirectToPage("login"); + } + + Span bytes = stackalloc byte[16]; + if (!Convert.TryFromBase64Chars(sessionIdCookie, bytes, out int bytesWritten) || bytesWritten < 16) + { + Response.Cookies.Delete("sid"); + return RedirectToPage("login"); + } + + var sessionId = new Guid(bytes); + if (!_sessionService.TryGetSession(sessionId, out ISession? session)) + { + Response.Cookies.Delete("sid"); + return RedirectToPage("login"); + } + + if (session.Expires <= DateTimeOffset.UtcNow) + { + _sessionService.DeleteSession(session); + Response.Cookies.Delete("sid"); + return RedirectToPage("login"); + } + + Span remoteAddressBytes = stackalloc byte[16]; + Span sessionAddressBytes = stackalloc byte[16]; + if (!remoteIpAddress.TryWriteBytes(remoteAddressBytes, out _) || + !session.IpAddress.TryWriteBytes(sessionAddressBytes, out _)) + { + Response.Cookies.Delete("sid"); + return RedirectToPage("login"); + } + + if (!remoteAddressBytes.SequenceEqual(sessionAddressBytes)) + { + Response.Cookies.Delete("sid"); + return RedirectToPage("login"); + } + + if (!_userService.TryGetUser(session.UserId, out IUser? user)) + { + Response.Cookies.Delete("sid"); + return RedirectToPage("login"); + } + + CurrentUser = user; + return Page(); + } +} diff --git a/OliverBooth/Pages/Blog/Admin/Login.cshtml b/OliverBooth/Pages/Blog/Admin/Login.cshtml new file mode 100644 index 0000000..fd5e551 --- /dev/null +++ b/OliverBooth/Pages/Blog/Admin/Login.cshtml @@ -0,0 +1,22 @@ +@page +@model OliverBooth.Pages.Blog.Admin.Login + +@{ + ViewData["Title"] = "Admin"; +} + +
+
+
+ + +
+ +
+ + +
+ + +
+
\ No newline at end of file diff --git a/OliverBooth/Pages/Blog/Admin/Login.cshtml.cs b/OliverBooth/Pages/Blog/Admin/Login.cshtml.cs new file mode 100644 index 0000000..a66c6eb --- /dev/null +++ b/OliverBooth/Pages/Blog/Admin/Login.cshtml.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using OtpNet; +using QRCoder; + +namespace OliverBooth.Pages.Blog.Admin; + +public class Login : PageModel +{ + public string QrCode { get; set; } + + public string Secret { get; set; } + + public IActionResult OnGet() + { + if (Request.Cookies.ContainsKey("sid")) + { + return RedirectToPage("index"); + } + + Secret = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20)); + + var uri = $"otpauth://totp/oliverbooth.dev?secret={Secret}"; + var generator = new QRCodeGenerator(); + QRCodeData qrCodeData = generator.CreateQrCode(uri, QRCodeGenerator.ECCLevel.Q); + using var pngByteQrCode = new PngByteQRCode(qrCodeData); + byte[] data = pngByteQrCode.GetGraphic(20); + QrCode = Convert.ToBase64String(data); + return Page(); + } +} diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index b471977..dc6ba32 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -36,6 +36,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); diff --git a/OliverBooth/Services/BlogUserService.cs b/OliverBooth/Services/BlogUserService.cs index 9b797eb..6db6c71 100644 --- a/OliverBooth/Services/BlogUserService.cs +++ b/OliverBooth/Services/BlogUserService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; using OliverBooth.Data.Blog; +using BC = BCrypt.Net.BCrypt; namespace OliverBooth.Services; @@ -35,4 +36,12 @@ internal sealed class BlogUserService : IBlogUserService if (user is not null) _userCache.TryAdd(id, user); return user is not null; } + + /// + public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user) + { + using BlogContext context = _dbContextFactory.CreateDbContext(); + user = context.Users.FirstOrDefault(u => u.EmailAddress == email); + return user is not null && BC.Verify(password, ((User)user).Password); + } } diff --git a/OliverBooth/Services/IBlogUserService.cs b/OliverBooth/Services/IBlogUserService.cs index 0a117ae..577509c 100644 --- a/OliverBooth/Services/IBlogUserService.cs +++ b/OliverBooth/Services/IBlogUserService.cs @@ -20,4 +20,18 @@ public interface IBlogUserService /// if a user with the specified ID is found; otherwise, . /// bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user); + + /// + /// Verifies the login information of the specified user. + /// + /// The email address. + /// The password. + /// + /// When this method returns, contains the user associated with the login credentials, or + /// if the credentials are invalid. + /// + /// + /// if the login credentials are valid; otherwise, . + /// + public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user); } diff --git a/OliverBooth/Services/ISessionService.cs b/OliverBooth/Services/ISessionService.cs new file mode 100644 index 0000000..0fb0ff6 --- /dev/null +++ b/OliverBooth/Services/ISessionService.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using OliverBooth.Data.Blog; +using ISession = OliverBooth.Data.Blog.ISession; + +namespace OliverBooth.Services; + +public interface ISessionService +{ + /// + /// Creates a new session for the specified user. + /// + /// The HTTP request. + /// The user. + /// The newly-created session. + /// + /// is . + /// -or- + /// is . + /// + ISession CreateSession(HttpRequest request, IUser user); + + /// + /// Deletes the specified session. + /// + /// The session to delete. + /// is . + void DeleteSession(ISession session); + + /// + /// Attempts to find a session with the specified ID. + /// + /// The session ID. + /// + /// When this method returns, contains the session with the specified ID, if the session is found; otherwise, + /// . + /// + /// + /// if a session with the specified ID is found; otherwise, . + /// + bool TryGetSession(Guid sessionId, [NotNullWhen(true)] out ISession? session); + + /// + /// Attempts to find the session associated with the HTTP request. + /// + /// The HTTP request. + /// + /// When this method returns, contains the session with the specified request, if the user is found; otherwise, + /// . + /// + /// + /// to include invalid sessions in the search; otherwise, . + /// + /// if the session was found; otherwise, . + /// is . + bool TryGetSession(HttpRequest request, [NotNullWhen(true)] out ISession? session, bool includeInvalid = false); +} \ No newline at end of file diff --git a/OliverBooth/Services/SessionService.cs b/OliverBooth/Services/SessionService.cs new file mode 100644 index 0000000..1fdaa8a --- /dev/null +++ b/OliverBooth/Services/SessionService.cs @@ -0,0 +1,123 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using OliverBooth.Data.Blog; +using OliverBooth.Data.Web; +using ISession = OliverBooth.Data.Blog.ISession; + +namespace OliverBooth.Services; + +internal sealed class SessionService : ISessionService +{ + private readonly ILogger _logger; + private readonly IBlogUserService _userService; + private readonly IDbContextFactory _blogContextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The user service. + /// The factory. + /// The factory. + public SessionService(ILogger logger, + IBlogUserService userService, + IDbContextFactory blogContextFactory, + IDbContextFactory webContextFactory) + { + _logger = logger; + _userService = userService; + _blogContextFactory = blogContextFactory; + } + + /// + public ISession CreateSession(HttpRequest request, IUser user) + { + if (request is null) throw new ArgumentNullException(nameof(request)); + if (user is null) throw new ArgumentNullException(nameof(user)); + + using BlogContext context = _blogContextFactory.CreateDbContext(); + var now = DateTimeOffset.UtcNow; + var session = new Session + { + UserId = user.Id, + IpAddress = request.HttpContext.Connection.RemoteIpAddress!, + Created = now, + Updated = now, + LastAccessed = now, + Expires = now + TimeSpan.FromDays(1), + RequiresTotp = !string.IsNullOrWhiteSpace(user.Totp) + }; + EntityEntry entry = context.Sessions.Add(session); + context.SaveChanges(); + return entry.Entity; + } + + /// + public void DeleteSession(ISession session) + { + using BlogContext context = _blogContextFactory.CreateDbContext(); + context.Sessions.Remove((Session)session); + context.SaveChanges(); + } + + /// + public bool TryGetSession(Guid sessionId, [NotNullWhen(true)] out ISession? session) + { + using BlogContext context = _blogContextFactory.CreateDbContext(); + session = context.Sessions.FirstOrDefault(s => s.Id == sessionId); + return session is not null; + } + + /// + public bool TryGetSession(HttpRequest request, [NotNullWhen(true)] out ISession? session, + bool includeInvalid = false) + { + if (request is null) throw new ArgumentNullException(nameof(request)); + + session = null; + IPAddress? remoteIpAddress = request.HttpContext.Connection.RemoteIpAddress; + if (remoteIpAddress is null) return false; + + if (!request.Cookies.TryGetValue("sid", out string? sessionIdCookie)) + return false; + + Span bytes = stackalloc byte[16]; + if (!Convert.TryFromBase64Chars(sessionIdCookie, bytes, out int bytesWritten) || bytesWritten < 16) + return false; + + var sessionId = new Guid(bytes); + if (!TryGetSession(sessionId, out session)) + return false; + + if (!includeInvalid && session.Expires >= DateTimeOffset.UtcNow) + { + session = null; + return false; + } + + Span remoteAddressBytes = stackalloc byte[16]; + Span sessionAddressBytes = stackalloc byte[16]; + if (!remoteIpAddress.TryWriteBytes(remoteAddressBytes, out _) || + !session.IpAddress.TryWriteBytes(sessionAddressBytes, out _)) + { + session = null; + return false; + } + + if (!includeInvalid && !remoteAddressBytes.SequenceEqual(sessionAddressBytes)) + { + session = null; + return false; + } + + if (!includeInvalid && _userService.TryGetUser(session.UserId, out _)) + { + session = null; + return false; + } + + return true; + } +}