feat: add blog admin page and simple login
This commit is contained in:
parent
f0aa1c0ae9
commit
8fda2e9907
78
OliverBooth/Controllers/Blog/AdminController.cs
Normal file
78
OliverBooth/Controllers/Blog/AdminController.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
24
OliverBooth/Data/Blog/Configuration/SessionConfiguration.cs
Normal file
24
OliverBooth/Data/Blog/Configuration/SessionConfiguration.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
84
OliverBooth/Data/Blog/ISession.cs
Normal file
84
OliverBooth/Data/Blog/ISession.cs
Normal 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; }
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
9
OliverBooth/Pages/Blog/Admin/Index.cshtml
Normal file
9
OliverBooth/Pages/Blog/Admin/Index.cshtml
Normal 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>
|
81
OliverBooth/Pages/Blog/Admin/Index.cshtml.cs
Normal file
81
OliverBooth/Pages/Blog/Admin/Index.cshtml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
22
OliverBooth/Pages/Blog/Admin/Login.cshtml
Normal file
22
OliverBooth/Pages/Blog/Admin/Login.cshtml
Normal 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>
|
31
OliverBooth/Pages/Blog/Admin/Login.cshtml.cs
Normal file
31
OliverBooth/Pages/Blog/Admin/Login.cshtml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
56
OliverBooth/Services/ISessionService.cs
Normal file
56
OliverBooth/Services/ISessionService.cs
Normal 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);
|
||||||
|
}
|
123
OliverBooth/Services/SessionService.cs
Normal file
123
OliverBooth/Services/SessionService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user