feat: add blog admin page and simple login

This commit is contained in:
Oliver Booth 2024-02-20 20:39:52 +00:00
parent f0aa1c0ae9
commit 8fda2e9907
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
16 changed files with 549 additions and 0 deletions

View File

@ -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<AdminController> _logger;
private readonly IBlogUserService _userService;
private readonly ISessionService _sessionService;
/// <summary>
/// Initializes a new instance of the <see cref="AdminController" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="userService">The user service.</param>
/// <param name="sessionService">The session service.</param>
public AdminController(ILogger<AdminController> 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<byte> 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");
}
}

View File

@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext
/// <value>The collection of blog posts.</value> /// <value>The collection of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; private set; } = null!; public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
/// <summary>
/// Gets the collection of sessions in the database.
/// </summary>
/// <value>The collection of sessions.</value>
public DbSet<Session> Sessions { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the collection of users in the database. /// Gets the collection of users in the database.
/// </summary> /// </summary>
@ -43,6 +49,7 @@ internal sealed class BlogContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
modelBuilder.ApplyConfiguration(new SessionConfiguration());
modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration());
} }
} }

View File

@ -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<Session>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<Session> 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<IPAddressToBytesConverter>().IsRequired();
builder.Property(e => e.RequiresTotp).IsRequired();
}
}

View File

@ -17,5 +17,6 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(e => e.Password).HasMaxLength(255).IsRequired(); builder.Property(e => e.Password).HasMaxLength(255).IsRequired();
builder.Property(e => e.Salt).HasMaxLength(255).IsRequired(); builder.Property(e => e.Salt).HasMaxLength(255).IsRequired();
builder.Property(e => e.Registered).IsRequired(); builder.Property(e => e.Registered).IsRequired();
builder.Property(e => e.Totp);
} }
} }

View File

@ -0,0 +1,84 @@
using System.Net;
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents a login session.
/// </summary>
public interface ISession
{
/// <summary>
/// Gets the date and time at which this session was created.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset Created { get; }
/// <summary>
/// Gets the date and time at which this session expires.
/// </summary>
/// <value>The expiration timestamp.</value>
DateTimeOffset Expires { get; }
/// <summary>
/// Gets the ID of the session.
/// </summary>
/// <value>The ID of the session.</value>
Guid Id { get; }
/// <summary>
/// Gets the IP address of the session.
/// </summary>
/// <value>The IP address.</value>
IPAddress IpAddress { get; }
/// <summary>
/// Gets the date and time at which this session was last accessed.
/// </summary>
/// <value>The last access timestamp.</value>
DateTimeOffset LastAccessed { get; }
/// <summary>
/// Gets a value indicating whether this session is valid.
/// </summary>
/// <value><see langword="true" /> if the session is valid; otherwise, <see langword="false" />.</value>
bool RequiresTotp { get; }
/// <summary>
/// Gets the date and time at which this session was updated.
/// </summary>
/// <value>The update timestamp.</value>
DateTimeOffset Updated { get; }
/// <summary>
/// Gets the user ID associated with the session.
/// </summary>
/// <value>The user ID.</value>
Guid UserId { get; }
}
internal sealed class Session : ISession
{
/// <inheritdoc />
public DateTimeOffset Created { get; set; }
/// <inheritdoc />
public DateTimeOffset Expires { get; set; }
/// <inheritdoc />
public Guid Id { get; private set; } = Guid.NewGuid();
/// <inheritdoc />
public IPAddress IpAddress { get; set; } = IPAddress.None;
/// <inheritdoc />
public DateTimeOffset LastAccessed { get; set; }
/// <inheritdoc />
public bool RequiresTotp { get; set; }
/// <inheritdoc />
public DateTimeOffset Updated { get; set; }
/// <inheritdoc />
public Guid UserId { get; set; }
}

View File

@ -35,6 +35,12 @@ public interface IUser
/// <value>The registration date and time.</value> /// <value>The registration date and time.</value>
DateTimeOffset Registered { get; } DateTimeOffset Registered { get; }
/// <summary>
/// Gets the user's TOTP token.
/// </summary>
/// <value>The TOTP token.</value>
string? Totp { get; }
/// <summary> /// <summary>
/// Gets the URL of the user's avatar. /// Gets the URL of the user's avatar.
/// </summary> /// </summary>

View File

@ -26,6 +26,9 @@ internal sealed class User : IUser, IBlogAuthor
/// <inheritdoc /> /// <inheritdoc />
public DateTimeOffset Registered { get; private set; } = DateTimeOffset.UtcNow; public DateTimeOffset Registered { get; private set; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public string? Totp { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the password hash. /// Gets or sets the password hash.
/// </summary> /// </summary>

View File

@ -0,0 +1,9 @@
@page
@model OliverBooth.Pages.Blog.Admin.Index
@{
ViewData["Title"] = "Admin";
}
<h1>Hello @(Model.CurrentUser.DisplayName)!</h1>
<a asp-controller="Admin" asp-action="Logout">Logout</a>

View File

@ -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<byte> 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<byte> remoteAddressBytes = stackalloc byte[16];
Span<byte> 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();
}
}

View File

@ -0,0 +1,22 @@
@page
@model OliverBooth.Pages.Blog.Admin.Login
@{
ViewData["Title"] = "Admin";
}
<div style="width: 256px; margin: 0 auto;">
<form method="post" asp-controller="Admin" asp-action="Login">
<div class="form-outline mb-4">
<label class="sr-only" for="login-email">Email address</label>
<input id="login-email" name="login-email" type="email" class="form-control" placeholder="Email address">
</div>
<div class="form-outline mb-4">
<label class="sr-only" for="login-password">Password</label>
<input id="login-password" name="login-password" type="password" class="form-control" placeholder="Password">
</div>
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
</form>
</div>

View File

@ -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();
}
}

View File

@ -36,6 +36,7 @@ builder.Services.AddSingleton<IContactService, ContactService>();
builder.Services.AddSingleton<ITemplateService, TemplateService>(); builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>(); builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IBlogUserService, BlogUserService>(); builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
builder.Services.AddSingleton<ISessionService, SessionService>();
builder.Services.AddSingleton<IProjectService, ProjectService>(); builder.Services.AddSingleton<IProjectService, ProjectService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>(); builder.Services.AddSingleton<IReadingListService, ReadingListService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation();

View File

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Blog; using OliverBooth.Data.Blog;
using BC = BCrypt.Net.BCrypt;
namespace OliverBooth.Services; namespace OliverBooth.Services;
@ -35,4 +36,12 @@ internal sealed class BlogUserService : IBlogUserService
if (user is not null) _userCache.TryAdd(id, user); if (user is not null) _userCache.TryAdd(id, user);
return user is not null; return user is not null;
} }
/// <inheritdoc />
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);
}
} }

View File

@ -20,4 +20,18 @@ public interface IBlogUserService
/// <see langword="true" /> if a user with the specified ID is found; otherwise, <see langword="false" />. /// <see langword="true" /> if a user with the specified ID is found; otherwise, <see langword="false" />.
/// </returns> /// </returns>
bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user); bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user);
/// <summary>
/// Verifies the login information of the specified user.
/// </summary>
/// <param name="email">The email address.</param>
/// <param name="password">The password.</param>
/// <param name="user">
/// When this method returns, contains the user associated with the login credentials, or
/// <see langword="null" /> if the credentials are invalid.
/// </param>
/// <returns>
/// <see langword="true" /> if the login credentials are valid; otherwise, <see langword="false" />.
/// </returns>
public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user);
} }

View File

@ -0,0 +1,56 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Blog;
using ISession = OliverBooth.Data.Blog.ISession;
namespace OliverBooth.Services;
public interface ISessionService
{
/// <summary>
/// Creates a new session for the specified user.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="user">The user.</param>
/// <returns>The newly-created session.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="request" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="user" /> is <see langword="null" />.</para>
/// </exception>
ISession CreateSession(HttpRequest request, IUser user);
/// <summary>
/// Deletes the specified session.
/// </summary>
/// <param name="session">The session to delete.</param>
/// <exception cref="ArgumentNullException"><paramref name="session" /> is <see langword="null" />.</exception>
void DeleteSession(ISession session);
/// <summary>
/// Attempts to find a session with the specified ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="session">
/// When this method returns, contains the session with the specified ID, if the session is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a session with the specified ID is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetSession(Guid sessionId, [NotNullWhen(true)] out ISession? session);
/// <summary>
/// Attempts to find the session associated with the HTTP request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="session">
/// When this method returns, contains the session with the specified request, if the user is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <param name="includeInvalid">
/// <see langword="true" /> to include invalid sessions in the search; otherwise, <see langword="false" />.
/// </param>
/// <returns><see langword="true" /> if the session was found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="request" /> is <see langword="null" />.</exception>
bool TryGetSession(HttpRequest request, [NotNullWhen(true)] out ISession? session, bool includeInvalid = false);
}

View File

@ -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<SessionService> _logger;
private readonly IBlogUserService _userService;
private readonly IDbContextFactory<BlogContext> _blogContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SessionService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="userService">The user service.</param>
/// <param name="blogContextFactory">The <see cref="BlogContext" /> factory.</param>
/// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param>
public SessionService(ILogger<SessionService> logger,
IBlogUserService userService,
IDbContextFactory<BlogContext> blogContextFactory,
IDbContextFactory<WebContext> webContextFactory)
{
_logger = logger;
_userService = userService;
_blogContextFactory = blogContextFactory;
}
/// <inheritdoc />
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<Session> entry = context.Sessions.Add(session);
context.SaveChanges();
return entry.Entity;
}
/// <inheritdoc />
public void DeleteSession(ISession session)
{
using BlogContext context = _blogContextFactory.CreateDbContext();
context.Sessions.Remove((Session)session);
context.SaveChanges();
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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<byte> 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<byte> remoteAddressBytes = stackalloc byte[16];
Span<byte> 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;
}
}