diff --git a/DBConnection/Repositories/BaseRepository.cs b/DBConnection/Repositories/BaseRepository.cs index 7bf6296..f81e1ee 100644 --- a/DBConnection/Repositories/BaseRepository.cs +++ b/DBConnection/Repositories/BaseRepository.cs @@ -84,8 +84,18 @@ public abstract class BaseRepository : IRepository whe return GetAllIncludedQueryable().FirstOrDefault(predicate); } + public virtual async Task> GetWhereIncluded(IEnumerable entities) + { + return await GetWhereIncluded(entities.Contains); + } + public virtual async Task> GetWhereIncluded(Func predicate) { return GetAllIncludedQueryable().AsEnumerable().Where(predicate); } + + public virtual async Task PersistChanges() + { + await DbContext.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/DBConnection/Repositories/Interfaces/IRepository.cs b/DBConnection/Repositories/Interfaces/IRepository.cs index 733a46b..6db87e9 100644 --- a/DBConnection/Repositories/Interfaces/IRepository.cs +++ b/DBConnection/Repositories/Interfaces/IRepository.cs @@ -16,4 +16,6 @@ public interface IRepository : IRepository where TEntityType : Base Task> GetWhereIncluded(Func predicate); Task> GetAllIncluded(); Task> UpsertMany(IEnumerable entities, bool saveAfter=true); + Task PersistChanges(); + Task> GetWhereIncluded(IEnumerable entities); } \ No newline at end of file diff --git a/DBConnection/Repositories/Interfaces/IUserRepository.cs b/DBConnection/Repositories/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..ed81571 --- /dev/null +++ b/DBConnection/Repositories/Interfaces/IUserRepository.cs @@ -0,0 +1,9 @@ +using Treestar.Shared.Models.DBDomain; + +namespace DBConnection.Repositories.Interfaces; + +public interface IUserRepository : IRepository +{ + Task AssignNovelsToUser(User user, List novels); + Task UpdateLastChapterRead(User user, Novel novel, int chapterRead); +} \ No newline at end of file diff --git a/DBConnection/Repositories/UserRepository.cs b/DBConnection/Repositories/UserRepository.cs new file mode 100644 index 0000000..df6c488 --- /dev/null +++ b/DBConnection/Repositories/UserRepository.cs @@ -0,0 +1,49 @@ +using DBConnection.Contexts; +using DBConnection.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; +using Treestar.Shared.Models.DBDomain; + +namespace DBConnection.Repositories; + +public class UserRepository : BaseRepository, IUserRepository +{ + private readonly INovelRepository _novelRepository; + public UserRepository(AppDbContext dbContext, INovelRepository novelRepository) : base(dbContext) + { + _novelRepository = novelRepository; + } + + protected override IQueryable GetAllIncludedQueryable() + { + return DbContext.Users.Include(u => u.WatchedNovels); + } + + public async Task AssignNovelsToUser(User user, List novels) + { + var dbUser = await GetIncluded(user); + if (dbUser == null) + { + return user; + } + + var dbNovels = await _novelRepository.GetWhereIncluded(novels); + var newNovels = dbNovels.Except(dbUser.WatchedNovels.Select(un => un.Novel)); + var newUserNovels = newNovels.Select(n => new UserNovel + { + Novel = n, + User = dbUser + }); + dbUser.WatchedNovels.AddRange(newUserNovels); + await DbContext.SaveChangesAsync(); + return dbUser; + } + + public async Task UpdateLastChapterRead(User user, Novel novel, int chapterRead) + { + var dbUser = await GetIncluded(user); + var userNovel = dbUser.WatchedNovels.FirstOrDefault(i => i.NovelUrl == novel.Url); + userNovel.LastChapterRead = chapterRead; + await DbContext.SaveChangesAsync(); + return dbUser; + } +} \ No newline at end of file diff --git a/Treestar.Shared/AccessLayers/ApiAccessLayer.cs b/Treestar.Shared/AccessLayers/ApiAccessLayer.cs index ee2dd8e..873529f 100644 --- a/Treestar.Shared/AccessLayers/ApiAccessLayer.cs +++ b/Treestar.Shared/AccessLayers/ApiAccessLayer.cs @@ -9,9 +9,11 @@ namespace Treestar.Shared.AccessLayers; public abstract class ApiAccessLayer { private readonly HttpClient _httpClient; + private readonly IAccessLayerAuthenticationProvider _authenticationProvider; - protected ApiAccessLayer(string apiBaseUrl) + protected ApiAccessLayer(string apiBaseUrl, IAccessLayerAuthenticationProvider authenticationProvider) { + _authenticationProvider = authenticationProvider; var handler = new HttpClientHandler() { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator @@ -22,6 +24,7 @@ public abstract class ApiAccessLayer private async Task SendRequest(HttpRequestMessage message) { + await _authenticationProvider.AddAuthentication(message); var response = await _httpClient.SendAsync(message); return new HttpResponseWrapper() { diff --git a/Treestar.Shared/AccessLayers/IAccessLayerAuthenticationProvider.cs b/Treestar.Shared/AccessLayers/IAccessLayerAuthenticationProvider.cs new file mode 100644 index 0000000..a9d98df --- /dev/null +++ b/Treestar.Shared/AccessLayers/IAccessLayerAuthenticationProvider.cs @@ -0,0 +1,6 @@ +namespace Treestar.Shared.AccessLayers; + +public interface IAccessLayerAuthenticationProvider +{ + Task AddAuthentication(HttpRequestMessage request); +} \ No newline at end of file diff --git a/Treestar.Shared/Authentication/JwtBearer/JWTAuthenticationExtension.cs b/Treestar.Shared/Authentication/JwtBearer/JWTAuthenticationExtension.cs new file mode 100644 index 0000000..c1e00c9 --- /dev/null +++ b/Treestar.Shared/Authentication/JwtBearer/JWTAuthenticationExtension.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Treestar.Shared.Authentication.JwtBearer; + +public static class JWTAuthenticationExtension +{ + public static void AddJwtBearerAuth(this IServiceCollection services, IConfiguration configuration) + { + var jwtAuthOptions = configuration.GetRequiredSection(JwtBearerAuthenticationOptions.ConfigrationSection) + .Get(); + services.AddAuthentication(opt => + { + opt.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(opt => + { + opt.Authority = jwtAuthOptions.Authority; + opt.Audience = jwtAuthOptions.Audience; + opt.TokenValidationParameters = new TokenValidationParameters + { + NameClaimType = ClaimTypes.Name, + ValidateAudience = !string.IsNullOrEmpty(jwtAuthOptions.Audience), + ValidateIssuer = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true + }; + }); + } +} \ No newline at end of file diff --git a/Treestar.Shared/Authentication/JwtBearer/JwtBearerAuthenticationOptions.cs b/Treestar.Shared/Authentication/JwtBearer/JwtBearerAuthenticationOptions.cs new file mode 100644 index 0000000..928c74a --- /dev/null +++ b/Treestar.Shared/Authentication/JwtBearer/JwtBearerAuthenticationOptions.cs @@ -0,0 +1,8 @@ +namespace Treestar.Shared.Authentication.JwtBearer; + +public class JwtBearerAuthenticationOptions +{ + public const string ConfigrationSection = "JwtBearerAuthOptions"; + public string Authority { get; set; } = null!; + public string? Audience { get; set; } +} \ No newline at end of file diff --git a/Treestar.Shared/Authentication/OIDC/AuthenticationExtension.cs b/Treestar.Shared/Authentication/OIDC/AuthenticationExtension.cs new file mode 100644 index 0000000..66cf5da --- /dev/null +++ b/Treestar.Shared/Authentication/OIDC/AuthenticationExtension.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using WebNovelPortal.Authentication; + +namespace Treestar.Shared.Authentication.OIDC; + +public static class AuthenticationExtension +{ + public static void AddOIDCAuth(this IServiceCollection services, IConfiguration configuration) + { + var oidcConfig = configuration.GetRequiredSection(OpenIdConnectAuthenticationOptions.ConfigurationSection) + .Get(); + services.AddAuthentication(opt => + { + opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddOpenIdConnect(opt => + { + opt.Authority = oidcConfig.Authority; + opt.ClientId = oidcConfig.ClientId; + opt.ClientSecret = oidcConfig.ClientSecret; + + opt.ResponseType = OpenIdConnectResponseType.Code; + opt.GetClaimsFromUserInfoEndpoint = false; + opt.SaveTokens = true; + opt.UseTokenLifetime = true; + foreach (var scope in oidcConfig.Scopes.Split(" ")) + { + opt.Scope.Add(scope); + } + + opt.TokenValidationParameters = new TokenValidationParameters + { + NameClaimType = ClaimTypes.Name + }; + }); + } +} \ No newline at end of file diff --git a/Treestar.Shared/Authentication/OIDC/OpenIdConnectAuthenticationOptions.cs b/Treestar.Shared/Authentication/OIDC/OpenIdConnectAuthenticationOptions.cs new file mode 100644 index 0000000..b12a1d5 --- /dev/null +++ b/Treestar.Shared/Authentication/OIDC/OpenIdConnectAuthenticationOptions.cs @@ -0,0 +1,10 @@ +namespace WebNovelPortal.Authentication; + +public class OpenIdConnectAuthenticationOptions +{ + public const string ConfigurationSection = "OIDCAuthOptions"; + public string Authority { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string Scopes { get; set; } +} \ No newline at end of file diff --git a/Treestar.Shared/Models/DBDomain/Novel.cs b/Treestar.Shared/Models/DBDomain/Novel.cs index 8c5c9ab..0f9a1dc 100644 --- a/Treestar.Shared/Models/DBDomain/Novel.cs +++ b/Treestar.Shared/Models/DBDomain/Novel.cs @@ -17,5 +17,23 @@ namespace Treestar.Shared.Models.DBDomain public NovelStatus Status { get; set; } public DateTime LastUpdated { get; set; } public DateTime DatePosted { get; set; } + + protected bool Equals(Novel other) + { + return Url == other.Url; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((Novel) obj); + } + + public override int GetHashCode() + { + return Url.GetHashCode(); + } } } \ No newline at end of file diff --git a/Treestar.Shared/Models/DBDomain/User.cs b/Treestar.Shared/Models/DBDomain/User.cs index db7a011..3871e45 100644 --- a/Treestar.Shared/Models/DBDomain/User.cs +++ b/Treestar.Shared/Models/DBDomain/User.cs @@ -10,5 +10,23 @@ namespace Treestar.Shared.Models.DBDomain public int Id { get; set; } public string Email { get; set; } public List WatchedNovels { get; set; } + + protected bool Equals(User other) + { + return Id == other.Id; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((User) obj); + } + + public override int GetHashCode() + { + return Id; + } } } \ No newline at end of file diff --git a/Treestar.Shared/Models/DBDomain/UserNovel.cs b/Treestar.Shared/Models/DBDomain/UserNovel.cs index f053a1a..2fbb07a 100644 --- a/Treestar.Shared/Models/DBDomain/UserNovel.cs +++ b/Treestar.Shared/Models/DBDomain/UserNovel.cs @@ -10,5 +10,23 @@ namespace Treestar.Shared.Models.DBDomain [JsonIgnore] public User User { get; set; } public int LastChapterRead { get; set; } + + protected bool Equals(UserNovel other) + { + return UserId == other.UserId && NovelUrl == other.NovelUrl; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((UserNovel) obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(UserId, NovelUrl); + } } } \ No newline at end of file diff --git a/Treestar.Shared/Treestar.Shared.csproj b/Treestar.Shared/Treestar.Shared.csproj index 07592e3..ae5da7b 100644 --- a/Treestar.Shared/Treestar.Shared.csproj +++ b/Treestar.Shared/Treestar.Shared.csproj @@ -7,14 +7,26 @@ + + + + + + ..\..\..\..\..\usr\share\dotnet\shared\Microsoft.AspNetCore.App\6.0.0\Microsoft.Extensions.Configuration.Abstractions.dll + + + ..\..\..\..\..\usr\share\dotnet\shared\Microsoft.AspNetCore.App\6.0.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll + + + diff --git a/WebNovelPortal/AccessLayers/WebApiAccessLayer.cs b/WebNovelPortal/AccessLayers/WebApiAccessLayer.cs index e4bcba0..36f7220 100644 --- a/WebNovelPortal/AccessLayers/WebApiAccessLayer.cs +++ b/WebNovelPortal/AccessLayers/WebApiAccessLayer.cs @@ -8,13 +8,13 @@ namespace WebNovelPortal.AccessLayers; public class WebApiAccessLayer : ApiAccessLayer { - public WebApiAccessLayer(string apiBaseUrl) : base(apiBaseUrl) + public WebApiAccessLayer(string apiBaseUrl, IAccessLayerAuthenticationProvider authenticationProvider) : base(apiBaseUrl, authenticationProvider) { } - public async Task?> GetNovels() + public async Task?> GetNovels() { - return (await SendRequest>("novel", HttpMethod.Get)).ResponseObject; + return (await SendRequest>("novel", HttpMethod.Get)).ResponseObject; } public async Task RequestNovelScrape(string url) @@ -23,9 +23,9 @@ public class WebApiAccessLayer : ApiAccessLayer new ScrapeNovelRequest {NovelUrl = url})).ResponseObject; } - public async Task GetNovel(string guid) + public async Task GetNovel(string guid) { - return (await SendRequest($"novel/{guid}", HttpMethod.Get)).ResponseObject; + return (await SendRequest($"novel/{guid}", HttpMethod.Get)).ResponseObject; } public async Task ScrapeNovels(List novelUrls) @@ -33,4 +33,13 @@ public class WebApiAccessLayer : ApiAccessLayer return (await SendRequest("novel/scrapeNovels", HttpMethod.Post, null, new ScrapeNovelsRequest {NovelUrls = novelUrls})).ResponseObject; } + + public async Task UpdateLastChapterRead(Novel novel, int chapter) + { + await SendRequest("novel/updateLastChapterRead", HttpMethod.Patch, new Dictionary + { + {"novelGuid", novel.Guid.ToString()}, + {"chapter", chapter.ToString()} + }, null); + } } \ No newline at end of file diff --git a/WebNovelPortal/App.razor b/WebNovelPortal/App.razor index c7730d1..2877fe9 100644 --- a/WebNovelPortal/App.razor +++ b/WebNovelPortal/App.razor @@ -1,12 +1,14 @@ - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
\ No newline at end of file + + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
+
\ No newline at end of file diff --git a/WebNovelPortal/Authentication/BlazorAccessLayerAuthProvider.cs b/WebNovelPortal/Authentication/BlazorAccessLayerAuthProvider.cs new file mode 100644 index 0000000..50b0c63 --- /dev/null +++ b/WebNovelPortal/Authentication/BlazorAccessLayerAuthProvider.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Treestar.Shared.AccessLayers; + +namespace WebNovelPortal.Authentication; + +public class BlazorAccessLayerAuthProvider : IAccessLayerAuthenticationProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + + public BlazorAccessLayerAuthProvider(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public async Task AddAuthentication(HttpRequestMessage request) + { + var idToken = await _httpContextAccessor.HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken); + if (!string.IsNullOrEmpty(idToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", idToken); + } + } +} \ No newline at end of file diff --git a/WebNovelPortal/Controllers/AccountController.cs b/WebNovelPortal/Controllers/AccountController.cs index 882200f..f22238e 100644 --- a/WebNovelPortal/Controllers/AccountController.cs +++ b/WebNovelPortal/Controllers/AccountController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,17 +14,21 @@ namespace WebNovelPortal.Controllers public class AccountController : ControllerBase { [HttpGet] - [Route("account/login")] - public async Task Login() + [Route("login")] + public async Task Login(string redirect="/") { - await HttpContext.ChallengeAsync(); + await HttpContext.ChallengeAsync(new AuthenticationProperties + { + RedirectUri = redirect + }); } [HttpGet] - [Route("account/logout")] + [Route("logout")] public async Task Logout() { await HttpContext.SignOutAsync(); + Response.Redirect("/"); } } } diff --git a/WebNovelPortal/Pages/Auth0InfoDump.razor b/WebNovelPortal/Pages/Auth0InfoDump.razor new file mode 100644 index 0000000..fac31d9 --- /dev/null +++ b/WebNovelPortal/Pages/Auth0InfoDump.razor @@ -0,0 +1,17 @@ +@page "/Auth0InfoDump" +@using Microsoft.AspNetCore.Authentication +@using Microsoft.IdentityModel.Protocols.OpenIdConnect +

Auth0InfoDump

+IdToken: @IdToken + +@code { + [Inject] private IHttpContextAccessor _contextAccessor { get; set; } + private string IdToken { get; set; } + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + var context = _contextAccessor.HttpContext; + IdToken = await context.GetTokenAsync(OpenIdConnectParameterNames.IdToken); + } + +} \ No newline at end of file diff --git a/WebNovelPortal/Pages/Index.razor b/WebNovelPortal/Pages/Index.razor index 89da7ce..b9e583f 100644 --- a/WebNovelPortal/Pages/Index.razor +++ b/WebNovelPortal/Pages/Index.razor @@ -7,20 +7,35 @@ return; } +@if (!authenticated) +{ +
you must login
+ return; +}
- + @code { [Inject] WebApiAccessLayer api { get; set; } + [CascadingParameter] + Task AuthenticationStateTask { get; set; } string NovelUrl { get; set; } - List novels = new List(); + List novels = new List(); protected bool loading = true; protected bool awaitingRequest = false; + protected bool authenticated = false; + + protected override async Task OnInitializedAsync() + { + authenticated = (await AuthenticationStateTask).User.Identity.IsAuthenticated; + SetLoading(authenticated); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) + if (firstRender && authenticated) { await RefreshNovels(); } @@ -45,9 +60,17 @@ async Task UpdateNovels() { SetAwaitingRequest(true); - var res = await api.ScrapeNovels(novels.Select(i => i.Url).ToList()); - SetAwaitingRequest(false); + var res = await api.ScrapeNovels(novels.Select(i => i.Novel.Url).ToList()); await RefreshNovels(); + SetAwaitingRequest(false); + } + + async Task UpdateNovel(Novel novel) + { + SetAwaitingRequest(true); + var res = await api.RequestNovelScrape(novel.Url); + await RefreshNovels(); + SetAwaitingRequest(false); } bool IsDisabled() diff --git a/WebNovelPortal/Pages/NovelDetails.razor b/WebNovelPortal/Pages/NovelDetails.razor index 32b025d..ad324bb 100644 --- a/WebNovelPortal/Pages/NovelDetails.razor +++ b/WebNovelPortal/Pages/NovelDetails.razor @@ -7,20 +7,24 @@ } else { -

@(Novel.Title)

-

Author: @(Novel.Author?.Name ?? "Anonymous")

-

Date Posted: @Novel.DatePosted

-

Date Updated: @Novel.LastUpdated

+

@(Novel.Novel.Title)

+

Author: @(Novel.Novel.Author?.Name ?? "Anonymous")

+

Date Posted: @Novel.Novel.DatePosted

+

Date Updated: @Novel.Novel.LastUpdated

Tags

    - @foreach (var tag in Novel.Tags) + @foreach (var tag in Novel.Novel.Tags) {
  • @tag.TagValue
  • }
-

Chapters

+

Chapters (Read / @Novel.Novel.Chapters.Count())

+ @if (currentLastChapter != originalLastChapter) + { + + }
    - @foreach (var chapter in Novel.Chapters.OrderBy(i => i.ChapterNumber)) + @foreach (var chapter in Novel.Novel.Chapters.OrderBy(i => i.ChapterNumber)) {
  1. @chapter.Name
  2. } @@ -32,16 +36,40 @@ else public string? NovelId { get; set; } [Inject] public WebApiAccessLayer api { get; set; } - Novel? Novel { get; set; } + UserNovel? Novel { get; set; } + int originalLastChapter; + int currentLastChapter; + bool awaitingRequest; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - Novel = await api.GetNovel(NovelId); - StateHasChanged(); + await UpdateNovelDetails(); } await base.OnAfterRenderAsync(firstRender); } + private async Task UpdateNovelDetails() + { + Novel = await api.GetNovel(NovelId); + originalLastChapter = Novel.LastChapterRead; + currentLastChapter = originalLastChapter; + StateHasChanged(); + } + + private async Task UpdateLastReadChapter() + { + SetAwaitingRequest(true); + await api.UpdateLastChapterRead(Novel.Novel, currentLastChapter); + await UpdateNovelDetails(); + SetAwaitingRequest(false); + } + + private void SetAwaitingRequest(bool enabled) + { + awaitingRequest = enabled; + StateHasChanged(); + } + } \ No newline at end of file diff --git a/WebNovelPortal/Program.cs b/WebNovelPortal/Program.cs index 68fcd31..4c56134 100644 --- a/WebNovelPortal/Program.cs +++ b/WebNovelPortal/Program.cs @@ -1,18 +1,24 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Newtonsoft.Json; +using Treestar.Shared.AccessLayers; +using Treestar.Shared.Authentication.OIDC; using WebNovelPortal.AccessLayers; +using WebNovelPortal.Authentication; var builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddScoped(fac => new WebApiAccessLayer(builder.Configuration["WebAPIUrl"])); +builder.Services.AddScoped(); +builder.Services.AddScoped(fac => new WebApiAccessLayer(builder.Configuration["WebAPIUrl"], fac.GetRequiredService())); builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddControllers().AddNewtonsoftJson(opt => { opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); +builder.Services.AddOIDCAuth(builder.Configuration); var app = builder.Build(); @@ -30,6 +36,9 @@ app.UseStaticFiles(); app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); app.MapControllers(); diff --git a/WebNovelPortal/Shared/Components/Display/LoginDisplay.razor b/WebNovelPortal/Shared/Components/Display/LoginDisplay.razor new file mode 100644 index 0000000..d13ebe8 --- /dev/null +++ b/WebNovelPortal/Shared/Components/Display/LoginDisplay.razor @@ -0,0 +1,21 @@ + + + Hello, @(LoggedInName)! + Log out + + + Log in + + + +@code { + [CascadingParameter] + Task AuthenticationState { get; set; } + private string LoggedInName { get; set; } + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + LoggedInName = (await AuthenticationState).User.Identity.Name; + } + +} diff --git a/WebNovelPortal/Shared/Components/Display/NovelList.razor b/WebNovelPortal/Shared/Components/Display/NovelList.razor index 2049fb0..7c53f66 100644 --- a/WebNovelPortal/Shared/Components/Display/NovelList.razor +++ b/WebNovelPortal/Shared/Components/Display/NovelList.razor @@ -1,4 +1,5 @@ @using Microsoft.AspNetCore.Components +@using WebNovelPortal.AccessLayers

    Novels

    @@ -12,16 +13,19 @@ { + } @@ -29,5 +33,13 @@ @code { [Parameter] - public List Novels { get; set; } + public List Novels { get; set; } + [Parameter] + public EventCallback HandleUpdateNovelRequest { get; set; } + [Parameter] + public bool InputDisabled { get; set; } + + [Inject] + private WebApiAccessLayer Api { get; set; } + } \ No newline at end of file diff --git a/WebNovelPortal/Shared/Layouts/MainLayout.razor b/WebNovelPortal/Shared/Layouts/MainLayout.razor index 02daf2e..b4be4d4 100644 --- a/WebNovelPortal/Shared/Layouts/MainLayout.razor +++ b/WebNovelPortal/Shared/Layouts/MainLayout.razor @@ -9,7 +9,7 @@
    - About +
    diff --git a/WebNovelPortal/Shared/Layouts/MainLayout.razor.css b/WebNovelPortal/Shared/Layouts/MainLayout.razor.css index ff2d2f4..5e25802 100644 --- a/WebNovelPortal/Shared/Layouts/MainLayout.razor.css +++ b/WebNovelPortal/Shared/Layouts/MainLayout.razor.css @@ -7,6 +7,7 @@ article { flex: 1 1 auto; + width: 100%; } main { diff --git a/WebNovelPortal/WebNovelPortal.csproj b/WebNovelPortal/WebNovelPortal.csproj index 06d286a..5089395 100644 --- a/WebNovelPortal/WebNovelPortal.csproj +++ b/WebNovelPortal/WebNovelPortal.csproj @@ -5,10 +5,10 @@ enable enable Linux + 32b7cf85-871e-439d-88a2-f8ff5186dafd - @@ -18,6 +18,7 @@ + diff --git a/WebNovelPortal/appsettings.json b/WebNovelPortal/appsettings.json index 9b02a3a..9669ec8 100644 --- a/WebNovelPortal/appsettings.json +++ b/WebNovelPortal/appsettings.json @@ -5,6 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, + "OIDCAuthOptions": { + "Authority": "authority_here", + "ClientId": "clientid_here", + "ClientSecret": "clientsecret_here", + "Scopes": "openid profile email" + }, "AllowedHosts": "*", "WebAPIUrl": "https://localhost:7137/api/" } diff --git a/WebNovelPortalAPI/Controllers/AuthorizedController.cs b/WebNovelPortalAPI/Controllers/AuthorizedController.cs new file mode 100644 index 0000000..4a41b55 --- /dev/null +++ b/WebNovelPortalAPI/Controllers/AuthorizedController.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Treestar.Shared.Models.DBDomain; +using WebNovelPortalAPI.Middleware; + +namespace WebNovelPortalAPI.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class AuthorizedController : ControllerBase + { + protected int UserId + { + get + { + return (int) (HttpContext.Items[EnsureUserCreatedMiddleware.UserIdItemName] ?? 0); + } + } + + public AuthorizedController() + { + } + + } +} diff --git a/WebNovelPortalAPI/Controllers/NovelController.cs b/WebNovelPortalAPI/Controllers/NovelController.cs index e782fe2..090b8db 100644 --- a/WebNovelPortalAPI/Controllers/NovelController.cs +++ b/WebNovelPortalAPI/Controllers/NovelController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using DBConnection; using DBConnection.Repositories; using DBConnection.Repositories.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Treestar.Shared.Models.DBDomain; @@ -18,15 +19,18 @@ namespace WebNovelPortalAPI.Controllers { [Route("api/[controller]")] [ApiController] - public class NovelController : ControllerBase + [Authorize] + public class NovelController : AuthorizedController { private readonly INovelRepository _novelRepository; + private readonly IUserRepository _userRepository; private readonly IEnumerable _scrapers; - public NovelController(IEnumerable scrapers, INovelRepository novelRepository) + public NovelController(IEnumerable scrapers, INovelRepository novelRepository, IUserRepository userRepository) { _scrapers = scrapers; _novelRepository = novelRepository; + _userRepository = userRepository; } private async Task ScrapeNovel(string url) @@ -45,17 +49,27 @@ namespace WebNovelPortalAPI.Controllers return _scrapers.FirstOrDefault(i => i.MatchesUrl(novelUrl)); } - [HttpGet] - [Route("{guid:guid}")] - public async Task GetNovel(Guid guid) + private async Task GetUser() { - return await _novelRepository.GetNovel(guid); + return await _userRepository.GetIncluded(u => u.Id == UserId); } [HttpGet] - public async Task> GetNovels() + [Route("{guid:guid}")] + public async Task GetNovel(Guid guid) { - return (await _novelRepository.GetAllIncluded()).ToList(); + var user = await GetUser(); + var novel = await _novelRepository.GetNovel(guid); + return user.WatchedNovels.FirstOrDefault(un => un.NovelUrl == novel.Url); + } + + [HttpGet] + public async Task> GetNovels() + { + var user = await GetUser(); + var novels = user.WatchedNovels.Select(i => i.Novel); + (await _novelRepository.GetWhereIncluded(novels)).ToList(); + return user.WatchedNovels.ToList(); } [HttpPost] @@ -75,11 +89,12 @@ namespace WebNovelPortalAPI.Controllers failures[novelUrl] = e; } } - - IEnumerable successfulUploads; + List successfulUploads; try { - successfulUploads = await _novelRepository.UpsertMany(successfulScrapes); + successfulUploads = (await _novelRepository.UpsertMany(successfulScrapes, true)).ToList(); + var user = await GetUser(); + await _userRepository.AssignNovelsToUser(user, successfulUploads); } catch (Exception e) { @@ -99,7 +114,9 @@ namespace WebNovelPortalAPI.Controllers try { var novel = await ScrapeNovel(request.NovelUrl); - var dbNovel = await _novelRepository.Upsert(novel); + var dbNovel = await _novelRepository.Upsert(novel, false); + var user = await GetUser(); + await _userRepository.AssignNovelsToUser(user, new List {novel}); return Ok(dbNovel); } catch (NoMatchingScraperException e) @@ -111,5 +128,15 @@ namespace WebNovelPortalAPI.Controllers return StatusCode(500, e); } } + + [HttpPatch] + [Route("updateLastChapterRead")] + public async Task UpdateLastChapterRead(Guid novelGuid, int chapter) + { + var user = await GetUser(); + var novel = await _novelRepository.GetNovel(novelGuid); + await _userRepository.UpdateLastChapterRead(user, novel, chapter); + return Ok(); + } } } diff --git a/WebNovelPortalAPI/Middleware/EnsureUserCreatedMiddleware.cs b/WebNovelPortalAPI/Middleware/EnsureUserCreatedMiddleware.cs new file mode 100644 index 0000000..ec08649 --- /dev/null +++ b/WebNovelPortalAPI/Middleware/EnsureUserCreatedMiddleware.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using DBConnection.Repositories.Interfaces; +using Microsoft.AspNetCore.Mvc.Filters; +using Treestar.Shared.Models.DBDomain; + +namespace WebNovelPortalAPI.Middleware; + +public class EnsureUserCreatedMiddleware : IMiddleware +{ + private readonly IUserRepository _userRepository; + public const string UserIdItemName = "userId"; + public EnsureUserCreatedMiddleware(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (!context.User.Identity.IsAuthenticated) + { + await next(context); + return; + } + var userEmail = context.User.Claims.FirstOrDefault(i => i.Type == ClaimTypes.Email)?.Value; + var dbUser = await _userRepository.GetIncluded(u => u.Email == userEmail) ?? await _userRepository.Upsert(new User + { + Email = userEmail + }); + context.Items[UserIdItemName] = dbUser.Id; + await next(context); + } +} \ No newline at end of file diff --git a/WebNovelPortalAPI/Program.cs b/WebNovelPortalAPI/Program.cs index 096cb28..9c4e640 100644 --- a/WebNovelPortalAPI/Program.cs +++ b/WebNovelPortalAPI/Program.cs @@ -2,8 +2,11 @@ using DBConnection; using DBConnection.Contexts; using DBConnection.Extensions; using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; using Newtonsoft.Json; +using Treestar.Shared.Authentication.JwtBearer; using WebNovelPortalAPI.Extensions; +using WebNovelPortalAPI.Middleware; using WebNovelPortalAPI.Scrapers; var builder = WebApplication.CreateBuilder(args); @@ -11,13 +14,40 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddDbServices(builder.Configuration); builder.Services.AddScrapers(); +builder.Services.AddJwtBearerAuth(builder.Configuration); +builder.Services.AddScoped(); builder.Services.AddControllers().AddNewtonsoftJson(opt => { opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(opt => +{ + opt.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Bearer token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "bearer" + }); + opt.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type=ReferenceType.SecurityScheme, + Id="Bearer" + } + }, + new string[]{} + } + }); +}); var app = builder.Build(); app.UpdateDatabase(); @@ -30,8 +60,9 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); - +app.UseMiddleware(); app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/WebNovelPortalAPI/WebNovelPortalAPI.csproj b/WebNovelPortalAPI/WebNovelPortalAPI.csproj index 78850ae..911db18 100644 --- a/WebNovelPortalAPI/WebNovelPortalAPI.csproj +++ b/WebNovelPortalAPI/WebNovelPortalAPI.csproj @@ -5,10 +5,12 @@ enable enable Linux + dd5e7c53-e576-4442-ae30-c496ec2070a5 + diff --git a/WebNovelPortalAPI/appsettings.json b/WebNovelPortalAPI/appsettings.json index 71c6c22..dcf5035 100644 --- a/WebNovelPortalAPI/appsettings.json +++ b/WebNovelPortalAPI/appsettings.json @@ -9,6 +9,10 @@ "Sqlite": "Data Source=test_db", "PostgresSql": "placeholder" }, + "JwtBearerAuthOptions": { + "Authority": "placeholder", + "Audience": "" + }, "DatabaseProvider": "Sqlite", "AllowedHosts": "*" }
    - @novel.Title + @novel.Novel.Title - @(novel.Author?.Name ?? "Anonymous") + @(novel.Novel.Author?.Name ?? "Anonymous") - @novel.Chapters.Count + @novel.Novel.Chapters.Count - @novel.LastUpdated + @novel.Novel.LastUpdated + +