From b5bfd341d7cdb093ed5a8f522262a894acbdb2cd Mon Sep 17 00:00:00 2001 From: Fesaa <77553571+Fesaa@users.noreply.github.com> Date: Sun, 3 Aug 2025 14:04:33 +0200 Subject: [PATCH] OpenID Connect support (#3975) Co-authored-by: DieselTech <30128380+DieselTech@users.noreply.github.com> Co-authored-by: majora2007 --- API.Tests/Extensions/EnumExtensionTests.cs | 22 + API.Tests/Services/AccountServiceTests.cs | 298 ++ API.Tests/Services/OidcServiceTests.cs | 582 +++ API.Tests/Services/SettingsServiceTests.cs | 2 +- API/Controllers/AccountController.cs | 139 +- API/Controllers/OidcController.cs | 37 + API/Controllers/SettingsController.cs | 31 + API/DTOs/Account/UpdateUserDto.cs | 2 + API/DTOs/MemberDto.cs | 2 + API/DTOs/Settings/AuthorityValidationDto.cs | 3 + API/DTOs/Settings/OidcConfigDto.cs | 68 + API/DTOs/Settings/OidcPublicConfigDto.cs | 24 + API/DTOs/Settings/ServerSettingDTO.cs | 5 + API/DTOs/Stats/V3/LibraryStatV3.cs | 4 + API/DTOs/Stats/V3/ServerInfoV3Dto.cs | 4 + API/DTOs/Stats/V3/UserStatV3.cs | 5 + API/DTOs/UserDto.cs | 7 +- API/Data/DataContext.cs | 4 + .../20250802103258_OpenIDConnect.Designer.cs | 3736 +++++++++++++++++ .../20250802103258_OpenIDConnect.cs | 39 + .../Migrations/DataContextModelSnapshot.cs | 11 +- API/Data/Repositories/UserRepository.cs | 20 +- API/Data/Seed.cs | 23 + API/Entities/AppUser.cs | 13 +- API/Entities/Enums/IdentityProvider.cs | 14 + API/Entities/Enums/ServerSettingKey.cs | 6 + .../ApplicationServiceExtensions.cs | 5 + API/Extensions/ClaimsPrincipalExtensions.cs | 29 +- API/Extensions/EnumExtensions.cs | 43 + API/Extensions/EnumerableExtensions.cs | 1 + API/Extensions/IdentityServiceExtensions.cs | 296 +- API/Extensions/StringExtensions.cs | 29 + API/Helpers/AutoMapperProfiles.cs | 3 +- .../Converters/ServerSettingConverter.cs | 4 + API/I18N/en.json | 4 + API/Services/AccountService.cs | 162 +- API/Services/OidcService.cs | 662 +++ API/Services/SettingsService.cs | 92 +- API/Services/Store/CustomTicketStore.cs | 59 + API/Services/Tasks/StatsService.cs | 6 +- API/Startup.cs | 2 +- Kavita.Common/Configuration.cs | 61 + Kavita.Common/Kavita.Common.csproj | 2 +- UI/Web/angular.json | 3 +- UI/Web/proxy.conf.json | 37 + UI/Web/src/app/_guards/auth.guard.ts | 14 +- .../app/_interceptors/error.interceptor.ts | 27 +- .../src/app/_interceptors/jwt.interceptor.ts | 20 +- UI/Web/src/app/_models/auth/member.ts | 2 + UI/Web/src/app/_models/user.ts | 8 + UI/Web/src/app/_pipes/age-rating.pipe.ts | 6 +- .../src/app/_pipes/identity-provider.pipe.ts | 19 + UI/Web/src/app/_services/account.service.ts | 50 +- UI/Web/src/app/_services/nav.service.ts | 20 +- UI/Web/src/app/admin/_models/oidc-config.ts | 25 + .../src/app/admin/_models/server-settings.ts | 2 + .../admin/edit-user/edit-user.component.html | 40 +- .../admin/edit-user/edit-user.component.scss | 3 + .../admin/edit-user/edit-user.component.ts | 65 +- .../library-selector.component.ts | 12 +- .../manage-open-idconnect.component.html | 282 ++ .../manage-open-idconnect.component.scss | 8 + .../manage-open-idconnect.component.ts | 206 + .../manage-settings.component.ts | 1 + .../manage-users/manage-users.component.html | 13 + .../manage-users/manage-users.component.ts | 23 +- .../role-selector/role-selector.component.ts | 10 +- UI/Web/src/app/admin/settings.service.ts | 14 +- UI/Web/src/app/app.component.ts | 7 +- .../user-login/user-login.component.html | 57 +- .../user-login/user-login.component.scss | 6 +- .../user-login/user-login.component.ts | 143 +- .../settings/settings.component.html | 8 + .../settings/settings.component.ts | 2 + .../preference-nav.component.ts | 2 + .../manage-reading-profiles.component.ts | 24 +- .../src/assets/icons/open-id-connect-logo.svg | 1 + UI/Web/src/assets/langs/en.json | 91 +- UI/Web/src/environments/environment.ts | 4 +- UI/Web/src/main.ts | 67 +- 80 files changed, 7604 insertions(+), 279 deletions(-) create mode 100644 API.Tests/Extensions/EnumExtensionTests.cs create mode 100644 API.Tests/Services/AccountServiceTests.cs create mode 100644 API.Tests/Services/OidcServiceTests.cs create mode 100644 API/Controllers/OidcController.cs create mode 100644 API/DTOs/Settings/AuthorityValidationDto.cs create mode 100644 API/DTOs/Settings/OidcConfigDto.cs create mode 100644 API/DTOs/Settings/OidcPublicConfigDto.cs create mode 100644 API/Data/Migrations/20250802103258_OpenIDConnect.Designer.cs create mode 100644 API/Data/Migrations/20250802103258_OpenIDConnect.cs create mode 100644 API/Entities/Enums/IdentityProvider.cs create mode 100644 API/Extensions/EnumExtensions.cs create mode 100644 API/Services/OidcService.cs create mode 100644 API/Services/Store/CustomTicketStore.cs create mode 100644 UI/Web/proxy.conf.json create mode 100644 UI/Web/src/app/_pipes/identity-provider.pipe.ts create mode 100644 UI/Web/src/app/admin/_models/oidc-config.ts create mode 100644 UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html create mode 100644 UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.scss create mode 100644 UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts create mode 100644 UI/Web/src/assets/icons/open-id-connect-logo.svg diff --git a/API.Tests/Extensions/EnumExtensionTests.cs b/API.Tests/Extensions/EnumExtensionTests.cs new file mode 100644 index 000000000..0e8b03f09 --- /dev/null +++ b/API.Tests/Extensions/EnumExtensionTests.cs @@ -0,0 +1,22 @@ +#nullable enable +using System; +using API.Entities.Enums; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class EnumExtensionTests +{ + + [Theory] + [InlineData("Early Childhood", AgeRating.EarlyChildhood, true)] + [InlineData("M", AgeRating.Mature, true)] + [InlineData("ThisIsNotAnAgeRating", default(AgeRating), false)] + public void TryParse(string? value, TEnum expected, bool success) where TEnum : struct, Enum + { + Assert.Equal(EnumExtensions.TryParse(value, out TEnum got), success); + Assert.Equal(expected, got); + } + +} diff --git a/API.Tests/Services/AccountServiceTests.cs b/API.Tests/Services/AccountServiceTests.cs new file mode 100644 index 000000000..107d36fa1 --- /dev/null +++ b/API.Tests/Services/AccountServiceTests.cs @@ -0,0 +1,298 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers.Builders; +using API.Services; +using API.Services.Tasks.Scanner; +using Kavita.Common; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class AccountServiceTests: AbstractDbTest +{ + + [Theory] + [InlineData("admin", true)] + [InlineData("^^$SomeBadChars", false)] + [InlineData("Lisa2003", true)] + [InlineData("Kraft Lawrance", false)] + public async Task ValidateUsername_Regex(string username, bool valid) + { + await ResetDb(); + var (_, accountService, _, _) = await Setup(); + + Assert.Equal(valid, !(await accountService.ValidateUsername(username)).Any()); + } + + [Fact] + public async Task ChangeIdentityProvider_Throws_WhenDefaultAdminUser() + { + await ResetDb(); + var (_, accountService, _, _) = await Setup(); + + var defaultAdmin = await UnitOfWork.UserRepository.GetDefaultAdminUser(); + + await Assert.ThrowsAsync(() => + accountService.ChangeIdentityProvider(defaultAdmin.Id, defaultAdmin, IdentityProvider.Kavita)); + } + + [Fact] + public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsIsFalse() + { + await ResetDb(); + var (user, accountService, _, _) = await Setup(); + + var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita); + + Assert.False(result); + + var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(updated); + Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider); + } + + [Fact] + public async Task ChangeIdentityProvider_Throws_WhenUserIsOidcManaged_AndNoChange() + { + await ResetDb(); + var (user, accountService, _, settingsService) = await Setup(); + + user.IdentityProvider = IdentityProvider.OpenIdConnect; + await UnitOfWork.CommitAsync(); + + var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync(); + settings.OidcConfig.SyncUserSettings = true; + await settingsService.UpdateSettings(settings); + + await Assert.ThrowsAsync(() => + accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect)); + } + + [Fact] + public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsTrue_AndChangeIsAllowed() + { + await ResetDb(); + var (user, accountService, _, settingsService) = await Setup(); + + user.IdentityProvider = IdentityProvider.OpenIdConnect; + await UnitOfWork.CommitAsync(); + + var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync(); + settings.OidcConfig.SyncUserSettings = true; + await settingsService.UpdateSettings(settings); + + var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita); + + Assert.False(result); + + var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(updated); + Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider); + } + + [Fact] + public async Task ChangeIdentityProvider_ReturnsTrue_WhenChangedToOidc() + { + await ResetDb(); + var (user, accountService, _, settingsService) = await Setup(); + + user.IdentityProvider = IdentityProvider.Kavita; + await UnitOfWork.CommitAsync(); + + var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync(); + settings.OidcConfig.SyncUserSettings = true; + await settingsService.UpdateSettings(settings); + + var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect); + + Assert.True(result); + + var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(updated); + Assert.Equal(IdentityProvider.OpenIdConnect, updated.IdentityProvider); + } + + [Fact] + public async Task UpdateLibrariesForUser_GrantsAccessToAllLibraries_WhenAdmin() + { + await ResetDb(); + var (user, accountService, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + await accountService.UpdateLibrariesForUser(user, new List(), hasAdminRole: true); + await UnitOfWork.CommitAsync(); + + var userLibs = await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id); + Assert.Equal(2, userLibs.Count()); + } + + [Fact] + public async Task UpdateLibrariesForUser_GrantsAccessToSelectedLibraries_WhenNotAdmin() + { + await ResetDb(); + var (user, accountService, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + await accountService.UpdateLibrariesForUser(user, new List { mangaLib.Id }, hasAdminRole: false); + await UnitOfWork.CommitAsync(); + + var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + Assert.Single(userLibs); + Assert.Equal(mangaLib.Id, userLibs.First().Id); + } + + [Fact] + public async Task UpdateLibrariesForUser_RemovesAccessFromUnselectedLibraries_WhenNotAdmin() + { + await ResetDb(); + var (user, accountService, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + // Grant access to both libraries + await accountService.UpdateLibrariesForUser(user, new List { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false); + await UnitOfWork.CommitAsync(); + + var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + Assert.Equal(2, userLibs.Count); + + // Now restrict access to only light novels + await accountService.UpdateLibrariesForUser(user, new List { lightNovelsLib.Id }, hasAdminRole: false); + await UnitOfWork.CommitAsync(); + + userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + Assert.Single(userLibs); + Assert.Equal(lightNovelsLib.Id, userLibs.First().Id); + } + + [Fact] + public async Task UpdateLibrariesForUser_GrantsNoLibraries_WhenNoneSelected_AndNotAdmin() + { + await ResetDb(); + var (user, accountService, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + // Initially grant access to both libraries + await accountService.UpdateLibrariesForUser(user, new List { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false); + await UnitOfWork.CommitAsync(); + + var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + Assert.Equal(2, userLibs.Count); + + // Now revoke all access by passing empty list + await accountService.UpdateLibrariesForUser(user, new List(), hasAdminRole: false); + await UnitOfWork.CommitAsync(); + + userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + Assert.Empty(userLibs); + } + + + + private async Task<(AppUser, IAccountService, UserManager, SettingsService)> Setup() + { + var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost") + .WithRole(PolicyConstants.AdminRole) + .Build(); + var user = new AppUserBuilder("amelia", "amelia@localhost").Build(); + + var roleStore = new RoleStore< + AppRole, + DataContext, + int, + IdentityUserRole, + IdentityRoleClaim + >(Context); + + var roleManager = new RoleManager( + roleStore, + [new RoleValidator()], + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + Substitute.For>>()); + + foreach (var role in PolicyConstants.ValidRoles) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new AppRole + { + Name = role, + }); + } + } + + var userStore = new UserStore< + AppUser, + AppRole, + DataContext, + int, + IdentityUserClaim, + AppUserRole, + IdentityUserLogin, + IdentityUserToken, + IdentityRoleClaim + >(Context); + var userManager = new UserManager(userStore, + new OptionsWrapper(new IdentityOptions()), + new PasswordHasher(), + [new UserValidator()], + [new PasswordValidator()], + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null!, + Substitute.For>>()); + + // Create users with the UserManager such that the SecurityStamp is set + await userManager.CreateAsync(user); + await userManager.CreateAsync(defaultAdmin); + + var accountService = new AccountService(userManager, Substitute.For>(), UnitOfWork, Mapper, Substitute.For()); + var settingsService = new SettingsService(UnitOfWork, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For> (), Substitute.For()); + + user = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.SideNavStreams); + return (user, accountService, userManager, settingsService); + } + + protected override async Task ResetDb() + { + Context.AppUser.RemoveRange(Context.AppUser); + Context.Library.RemoveRange(Context.Library); + await UnitOfWork.CommitAsync(); + } +} diff --git a/API.Tests/Services/OidcServiceTests.cs b/API.Tests/Services/OidcServiceTests.cs new file mode 100644 index 000000000..1619c1956 --- /dev/null +++ b/API.Tests/Services/OidcServiceTests.cs @@ -0,0 +1,582 @@ +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class OidcServiceTests: AbstractDbTest +{ + + [Fact] + public async Task UserSync_Username() + { + await ResetDb(); + var (oidcService, _, _, userManager) = await Setup(); + + var user = new AppUserBuilder("holo", "holo@localhost").Build(); + var res = await userManager.CreateAsync(user); + Assert.Empty(res.Errors); + Assert.True(res.Succeeded); + + var claims = new List() + { + new (ClaimTypes.Name, "amelia"), + new (ClaimTypes.GivenName, "Lawrence"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + // name is updated as the current username is not found, amelia is skipped as it is alredy in use + await oidcService.SyncUserSettings(null!, settings, principal, user); + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal("Lawrence", user.UserName); + + claims = new List() + { + new (ClaimTypes.Name, "amelia"), + new (ClaimTypes.GivenName, "Lawrence"), + new (ClaimTypes.Surname, "Norah"), + }; + identity = new ClaimsIdentity(claims); + principal = new ClaimsPrincipal(identity); + + // Ensure a name longer down the list isn't picked if the current username is found + await oidcService.SyncUserSettings(null!, settings, principal, user); + dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal("Lawrence", user.UserName); + } + + [Fact] + public async Task UserSync_CustomClaim() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + const string claim = "groups"; + var claims = new List() + { + new (claim, PolicyConstants.LoginRole), + new (claim, PolicyConstants.DownloadRole), + new (ClaimTypes.Role, PolicyConstants.PromoteRole), + new (claim, OidcService.AgeRestrictionPrefix + "M"), + new (claim, OidcService.LibraryAccessPrefix + "Manga"), + new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + RolesClaim = claim, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + // Check correct roles assigned + var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); + Assert.Contains(PolicyConstants.LoginRole, userRoles); + Assert.Contains(PolicyConstants.DownloadRole, userRoles); + Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles); + + // Check correct libraries + var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); + Assert.Single(libraries); + Assert.Contains(mangaLib.Name, libraries); + Assert.DoesNotContain(lightNovelsLib.Name, libraries); + + // Check correct age restrictions + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); + Assert.False(dbUser.AgeRestrictionIncludeUnknowns); + } + + [Fact] + public async Task UserSync_CustomPrefix() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + const string prefix = "kavita-"; + var claims = new List() + { + new (ClaimTypes.Role, prefix + PolicyConstants.LoginRole), + new (ClaimTypes.Role, prefix + PolicyConstants.DownloadRole), + new (ClaimTypes.Role, PolicyConstants.PromoteRole), + new (ClaimTypes.Role, prefix + OidcService.AgeRestrictionPrefix + "M"), + new (ClaimTypes.Role, prefix + OidcService.LibraryAccessPrefix + "Manga"), + new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + RolesPrefix = prefix, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + // Check correct roles assigned + var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); + Assert.Contains(PolicyConstants.LoginRole, userRoles); + Assert.Contains(PolicyConstants.DownloadRole, userRoles); + Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles); + + // Check correct libraries + var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); + Assert.Single(libraries); + Assert.Contains(mangaLib.Name, libraries); + Assert.DoesNotContain(lightNovelsLib.Name, libraries); + + // Check correct age restrictions + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); + Assert.False(dbUser.AgeRestrictionIncludeUnknowns); + } + + [Fact] + public async Task SyncRoles() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var claims = new List() + { + new (ClaimTypes.Role, PolicyConstants.LoginRole), + new (ClaimTypes.Role, PolicyConstants.DownloadRole), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); + Assert.Contains(PolicyConstants.LoginRole, userRoles); + Assert.Contains(PolicyConstants.DownloadRole, userRoles); + + // Only give one role + claims = [new Claim(ClaimTypes.Role, PolicyConstants.LoginRole)]; + identity = new ClaimsIdentity(claims); + principal = new ClaimsPrincipal(identity); + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id); + Assert.Contains(PolicyConstants.LoginRole, userRoles); + Assert.DoesNotContain(PolicyConstants.DownloadRole, userRoles); + } + + [Fact] + public async Task SyncLibraries() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build(); + var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build(); + + UnitOfWork.LibraryRepository.Add(mangaLib); + UnitOfWork.LibraryRepository.Add(lightNovelsLib); + await UnitOfWork.CommitAsync(); + + var claims = new List() + { + new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Manga"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); + Assert.Single(libraries); + Assert.Contains(mangaLib.Name, libraries); + Assert.DoesNotContain(lightNovelsLib.Name, libraries); + + // Only give access to the other library + claims = [new Claim(ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels")]; + identity = new ClaimsIdentity(claims); + principal = new ClaimsPrincipal(identity); + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + // Check access has swicthed + libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList(); + Assert.Single(libraries); + Assert.Contains(lightNovelsLib.Name, libraries); + Assert.DoesNotContain(mangaLib.Name, libraries); + } + + [Fact] + public async Task SyncAgeRestrictions_NoRestrictions() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var claims = new List() + { + new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Not Applicable"), + new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); + Assert.True(dbUser.AgeRestrictionIncludeUnknowns); + } + + [Fact] + public async Task SyncAgeRestrictions_IncludeUnknowns() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var claims = new List() + { + new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"), + new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); + Assert.True(dbUser.AgeRestrictionIncludeUnknowns); + } + + [Fact] + public async Task SyncAgeRestriction_AdminNone() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var claims = new List() + { + new (ClaimTypes.Role, PolicyConstants.AdminRole), + new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); + Assert.True(dbUser.AgeRestrictionIncludeUnknowns); + } + + [Fact] + public async Task SyncAgeRestriction_MultipleAgeRestrictionClaims() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var claims = new List() + { + new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"), + new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction); + } + + [Fact] + public async Task SyncAgeRestriction_NoAgeRestrictionClaims() + { + await ResetDb(); + var (oidcService, user, _, _) = await Setup(); + + var identity = new ClaimsIdentity([]); + var principal = new ClaimsPrincipal(identity); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); + Assert.True(dbUser.AgeRestrictionIncludeUnknowns); + + // Also default to no restrictions when only include unknowns is present + identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns)]); + principal = new ClaimsPrincipal(identity); + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(dbUser); + Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction); + Assert.True(dbUser.AgeRestrictionIncludeUnknowns); + } + + [Fact] + public async Task SyncUserSettings_DontChangeDefaultAdmin() + { + await ResetDb(); + var (oidcService, _, _, userManager) = await Setup(); + + // Make user default user + var user = await UnitOfWork.UserRepository.GetDefaultAdminUser(); + + var settings = new OidcConfigDto + { + SyncUserSettings = true, + }; + + var claims = new List() + { + new (ClaimTypes.Role, PolicyConstants.ChangePasswordRole), + new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + await oidcService.SyncUserSettings(null!, settings, principal, user); + + var userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id); + Assert.NotNull(userFromDb); + Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction); + + var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost").Build(); + var res = await userManager.CreateAsync(newUser); + Assert.Empty(res.Errors); + Assert.True(res.Succeeded); + + await oidcService.SyncUserSettings(null!, settings, principal, newUser); + userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(newUser.Id); + Assert.NotNull(userFromDb); + Assert.True(await userManager.IsInRoleAsync(newUser, PolicyConstants.ChangePasswordRole)); + Assert.Equal(AgeRating.Teen, userFromDb.AgeRestriction); + + } + + [Fact] + public async Task FindBestAvailableName_NoDuplicates() + { + await ResetDb(); + var (oidcService, _, _, userManager) = await Setup(); + + + const string preferredName = "PreferredName"; + const string name = "Name"; + const string givenName = "GivenName"; + const string surname = "Surname"; + const string email = "Email"; + + var claims = new List() + { + new(JwtRegisteredClaimNames.PreferredUsername, preferredName), + new(ClaimTypes.Name, name), + new(ClaimTypes.GivenName, givenName), + new(ClaimTypes.Surname, surname), + new(ClaimTypes.Email, email), + }; + + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var bestName = await oidcService.FindBestAvailableName(principal); + Assert.NotNull(bestName); + Assert.Equal(preferredName, bestName); + + // Create user with this name to make the method fallback to the next claim + var user = new AppUserBuilder(bestName, bestName).Build(); + var res = await userManager.CreateAsync(user); + // This has actual information as to why it would fail, so we check it to make sure if the test fail here we know why + Assert.Empty(res.Errors); + Assert.True(res.Succeeded); + + // Fallback to name + bestName = await oidcService.FindBestAvailableName(principal); + Assert.NotNull(bestName); + Assert.Equal(name, bestName); + + user = new AppUserBuilder(bestName, bestName).Build(); + res = await userManager.CreateAsync(user); + Assert.Empty(res.Errors); + Assert.True(res.Succeeded); + + // Fallback to given name + bestName = await oidcService.FindBestAvailableName(principal); + Assert.NotNull(bestName); + Assert.Equal(givenName, bestName); + + user = new AppUserBuilder(bestName, bestName).Build(); + res = await userManager.CreateAsync(user); + Assert.Empty(res.Errors); + Assert.True(res.Succeeded); + + // Fallback to surname + bestName = await oidcService.FindBestAvailableName(principal); + Assert.NotNull(bestName); + Assert.Equal(surname, bestName); + + user = new AppUserBuilder(bestName, bestName).Build(); + res = await userManager.CreateAsync(user); + Assert.Empty(res.Errors); + Assert.True(res.Succeeded); + + // When none are found, returns null + bestName = await oidcService.FindBestAvailableName(principal); + Assert.Null(bestName); + } + + private async Task<(OidcService, AppUser, IAccountService, UserManager)> Setup() + { + var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost") + .WithRole(PolicyConstants.AdminRole) + .Build(); + var user = new AppUserBuilder("amelia", "amelia@localhost").Build(); + + var roleStore = new RoleStore< + AppRole, + DataContext, + int, + IdentityUserRole, + IdentityRoleClaim + >(Context); + + var roleManager = new RoleManager( + roleStore, + [new RoleValidator()], + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + Substitute.For>>()); + + foreach (var role in PolicyConstants.ValidRoles) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new AppRole + { + Name = role, + }); + } + } + + var userStore = new UserStore< + AppUser, + AppRole, + DataContext, + int, + IdentityUserClaim, + AppUserRole, + IdentityUserLogin, + IdentityUserToken, + IdentityRoleClaim + >(Context); + var userManager = new UserManager(userStore, + new OptionsWrapper(new IdentityOptions()), + new PasswordHasher(), + [new UserValidator()], + [new PasswordValidator()], + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null!, + Substitute.For>>()); + + // Create users with the UserManager such that the SecurityStamp is set + await userManager.CreateAsync(user); + await userManager.CreateAsync(defaultAdmin); + + var accountService = new AccountService(userManager, Substitute.For>(), UnitOfWork, Mapper, Substitute.For()); + var oidcService = new OidcService(Substitute.For>(), userManager, UnitOfWork, accountService, Substitute.For()); + return (oidcService, user, accountService, userManager); + } + + protected override async Task ResetDb() + { + Context.AppUser.RemoveRange(Context.AppUser); + Context.Library.RemoveRange(Context.Library); + await UnitOfWork.CommitAsync(); + } +} diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs index 074237d2f..5e02b0b8a 100644 --- a/API.Tests/Services/SettingsServiceTests.cs +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -34,7 +34,7 @@ public class SettingsServiceTests _mockUnitOfWork = Substitute.For(); _settingsService = new SettingsService(_mockUnitOfWork, ds, Substitute.For(), Substitute.For(), - Substitute.For>()); + Substitute.For>(), Substitute.For()); } #region ImportMetadataSettings diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index d8b9164af..0b0a17160 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -10,6 +10,7 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.Account; using API.DTOs.Email; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Errors; @@ -52,6 +53,7 @@ public class AccountController : BaseApiController private readonly IEmailService _emailService; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly IOidcService _oidcService; /// public AccountController(UserManager userManager, @@ -60,7 +62,8 @@ public class AccountController : BaseApiController ILogger logger, IMapper mapper, IAccountService accountService, IEmailService emailService, IEventHub eventHub, - ILocalizationService localizationService) + ILocalizationService localizationService, + IOidcService oidcService) { _userManager = userManager; _signInManager = signInManager; @@ -72,6 +75,50 @@ public class AccountController : BaseApiController _emailService = emailService; _eventHub = eventHub; _localizationService = localizationService; + _oidcService = oidcService; + } + + /// + /// Returns true if OIDC authentication cookies are present + /// + /// Makes not guarantee about their validity + /// + [AllowAnonymous] + [HttpGet("oidc-authenticated")] + public ActionResult OidcAuthenticated() + { + return HttpContext.Request.Cookies.ContainsKey(OidcService.CookieName); + } + + /// + /// Returns the current user, as it would from login + /// + /// + /// + /// Does not return tokens for the user + /// Updates the last active date for the user + [HttpGet] + public async Task> GetCurrentUserAsync() + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); + if (user == null) throw new UnauthorizedAccessException(); + + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); + + try + { + user.UpdateLastActive(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName); + } + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + return Ok(await ConstructUserDto(user, roles, false)); } /// @@ -151,10 +198,10 @@ public class AccountController : BaseApiController if (!result.Succeeded) return BadRequest(result.Errors); // Assign default streams - AddDefaultStreamsToUser(user); + _accountService.AddDefaultStreamsToUser(user); // Assign default reading profile - await AddDefaultReadingProfileToUser(user); + await _accountService.AddDefaultReadingProfileToUser(user); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen")); @@ -224,6 +271,11 @@ public class AccountController : BaseApiController var roles = await _userManager.GetRolesAsync(user); if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); + var oidcConfig = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + // Setting only takes effect if OIDC is functional, and if we're not logging in via ApiKey + var disablePasswordAuthentication = oidcConfig is {Enabled: true, DisablePasswordAuthentication: true} && string.IsNullOrEmpty(loginDto.ApiKey); + if (disablePasswordAuthentication && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "password-authentication-disabled")); + if (string.IsNullOrEmpty(loginDto.ApiKey)) { var result = await _signInManager @@ -249,7 +301,14 @@ public class AccountController : BaseApiController } // Update LastActive on account - user.UpdateLastActive(); + try + { + user.UpdateLastActive(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName); + } // NOTE: This can likely be removed user.UserPreferences ??= new AppUserPreferences @@ -262,18 +321,28 @@ public class AccountController : BaseApiController _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); + return Ok(await ConstructUserDto(user, roles)); + } + + private async Task ConstructUserDto(AppUser user, IList roles, bool includeTokens = true) + { var dto = _mapper.Map(user); - dto.Token = await _tokenService.CreateToken(user); - dto.RefreshToken = await _tokenService.CreateRefreshToken(user); - dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)) - .Value; + + if (includeTokens) + { + dto.Token = await _tokenService.CreateToken(user); + dto.RefreshToken = await _tokenService.CreateRefreshToken(user); + } + + dto.Roles = roles; + dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value; + var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName!); - if (pref == null) return Ok(dto); + if (pref == null) return dto; pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); dto.Preferences = _mapper.Map(pref); - - return Ok(dto); + return dto; } /// @@ -286,13 +355,9 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences); if (user == null) return Unauthorized(); - var dto = _mapper.Map(user); - dto.Token = await _tokenService.CreateToken(user); - dto.RefreshToken = await _tokenService.CreateRefreshToken(user); - dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)) - .Value; - dto.Preferences = _mapper.Map(user.UserPreferences); - return Ok(dto); + var roles = await _userManager.GetRolesAsync(user); + + return Ok(await ConstructUserDto(user, roles, !HttpContext.Request.Cookies.ContainsKey(OidcService.CookieName))); } /// @@ -505,6 +570,7 @@ public class AccountController : BaseApiController /// /// /// + /// Users who's is not cannot be edited if is true [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] public async Task UpdateAccount(UpdateUserDto dto) @@ -517,6 +583,16 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); + + try + { + if (await _accountService.ChangeIdentityProvider(User.GetUserId(), user, dto.IdentityProvider)) return Ok(); + } + catch (KavitaException exception) + { + return BadRequest(exception.Message); + } + // Check if username is changing if (!user.UserName!.Equals(dto.Username)) { @@ -670,10 +746,10 @@ public class AccountController : BaseApiController if (!result.Succeeded) return BadRequest(result.Errors); // Assign default streams - AddDefaultStreamsToUser(user); + _accountService.AddDefaultStreamsToUser(user); // Assign default reading profile - await AddDefaultReadingProfileToUser(user); + await _accountService.AddDefaultReadingProfileToUser(user); // Assign Roles var roles = dto.Roles; @@ -772,29 +848,6 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user")); } - private void AddDefaultStreamsToUser(AppUser user) - { - foreach (var newStream in Seed.DefaultStreams.Select(stream => _mapper.Map(stream))) - { - user.DashboardStreams.Add(newStream); - } - - foreach (var stream in Seed.DefaultSideNavStreams.Select(stream => _mapper.Map(stream))) - { - user.SideNavStreams.Add(stream); - } - } - - private async Task AddDefaultReadingProfileToUser(AppUser user) - { - var profile = new AppUserReadingProfileBuilder(user.Id) - .WithName("Default Profile") - .WithKind(ReadingProfileKind.Default) - .Build(); - _unitOfWork.AppUserReadingProfileRepository.Add(profile); - await _unitOfWork.CommitAsync(); - } - /// /// Last step in authentication flow, confirms the email token for email /// diff --git a/API/Controllers/OidcController.cs b/API/Controllers/OidcController.cs new file mode 100644 index 000000000..aefa361ef --- /dev/null +++ b/API/Controllers/OidcController.cs @@ -0,0 +1,37 @@ +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Route("[controller]")] +public class OidcController: ControllerBase +{ + + [AllowAnonymous] + [HttpGet("login")] + public IActionResult Login(string returnUrl = "/") + { + var properties = new AuthenticationProperties { RedirectUri = returnUrl }; + return Challenge(properties, IdentityServiceExtensions.OpenIdConnect); + } + + [HttpGet("logout")] + public IActionResult Logout() + { + + if (!Request.Cookies.ContainsKey(OidcService.CookieName)) + { + return Redirect("/"); + } + + return SignOut( + new AuthenticationProperties { RedirectUri = "/login" }, + CookieAuthenticationDefaults.AuthenticationScheme, + IdentityServiceExtensions.OpenIdConnect); + } + +} diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 71153738a..5a9ad49f8 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -274,4 +274,35 @@ public class SettingsController : BaseApiController } } + + /// + /// Retrieve publicly required configuration regarding Oidc + /// + /// + [AllowAnonymous] + [HttpGet("oidc")] + public async Task> GetOidcConfig() + { + var settings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + var publicConfig = _mapper.Map(settings); + publicConfig.Enabled = !string.IsNullOrEmpty(settings.Authority) && + !string.IsNullOrEmpty(settings.ClientId) && + !string.IsNullOrEmpty(settings.Secret); + + return Ok(publicConfig); + } + + /// + /// Validate if the given authority is reachable from the server + /// + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("is-valid-authority")] + public async Task> IsValidAuthority([FromBody] AuthorityValidationDto authority) + { + return Ok(await _settingsService.IsValidAuthority(authority.Authority)); + } + + } diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index 0cb0eaf66..52ebec40f 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; namespace API.DTOs.Account; #nullable enable @@ -25,4 +26,5 @@ public sealed record UpdateUserDto public AgeRestrictionDto AgeRestriction { get; init; } = default!; /// public string? Email { get; set; } = default!; + public IdentityProvider IdentityProvider { get; init; } = IdentityProvider.Kavita; } diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index f5f24b284..5dcd33aa1 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.DTOs.Account; +using API.Entities.Enums; namespace API.DTOs; #nullable enable @@ -24,4 +25,5 @@ public sealed record MemberDto public DateTime LastActiveUtc { get; init; } public IEnumerable? Libraries { get; init; } public IEnumerable? Roles { get; init; } + public IdentityProvider IdentityProvider { get; init; } } diff --git a/API/DTOs/Settings/AuthorityValidationDto.cs b/API/DTOs/Settings/AuthorityValidationDto.cs new file mode 100644 index 000000000..e7ea2ae18 --- /dev/null +++ b/API/DTOs/Settings/AuthorityValidationDto.cs @@ -0,0 +1,3 @@ +namespace API.DTOs.Settings; + +public sealed record AuthorityValidationDto(string Authority); diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs new file mode 100644 index 000000000..db065b5f0 --- /dev/null +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -0,0 +1,68 @@ +#nullable enable + +using System.Collections.Generic; +using System.Security.Claims; +using API.Entities.Enums; + +namespace API.DTOs.Settings; + +/// +/// All configuration regarding OIDC +/// +/// This class is saved as a JsonObject in the DB, assign default values to prevent unexpected NPE +public sealed record OidcConfigDto: OidcPublicConfigDto +{ + /// + /// Optional OpenID Connect Authority URL. Not managed in DB. Managed in appsettings.json and synced to DB. + /// + public string Authority { get; set; } = string.Empty; + /// + /// Optional OpenID Connect ClientId, defaults to kavita. Not managed in DB. Managed in appsettings.json and synced to DB. + /// + public string ClientId { get; set; } = string.Empty; + /// + /// Optional OpenID Connect Secret. Not managed in DB. Managed in appsettings.json and synced to DB. + /// + public string Secret { get; set; } = string.Empty; + /// + /// If true, auto creates a new account when someone logs in via OpenID Connect + /// + public bool ProvisionAccounts { get; set; } = false; + /// + /// Require emails to be verified by the OpenID Connect provider when creating accounts on login + /// + public bool RequireVerifiedEmail { get; set; } = true; + /// + /// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provided roles on log in. + /// + public bool SyncUserSettings { get; set; } = false; + /// + /// A prefix that all roles Kavita checks for during sync must have + /// + public string RolesPrefix { get; set; } = string.Empty; + /// + /// The JWT claim roles are mapped under, defaults to + /// + public string RolesClaim { get; set; } = ClaimTypes.Role; + /// + /// Custom scopes Kavita should request from your OIDC provider + /// + /// Advanced setting + public List CustomScopes { get; set; } = []; + + // Default values used when SyncUserSettings is false + #region Default user settings + + public List DefaultRoles { get; set; } = []; + public List DefaultLibraries { get; set; } = []; + public AgeRating DefaultAgeRestriction { get; set; } = AgeRating.Unknown; + public bool DefaultIncludeUnknowns { get; set; } = false; + + #endregion + + + /// + /// Returns true if the has been set + /// + public bool Enabled => !string.IsNullOrEmpty(Authority); +} diff --git a/API/DTOs/Settings/OidcPublicConfigDto.cs b/API/DTOs/Settings/OidcPublicConfigDto.cs new file mode 100644 index 000000000..6843adcca --- /dev/null +++ b/API/DTOs/Settings/OidcPublicConfigDto.cs @@ -0,0 +1,24 @@ +#nullable enable + +namespace API.DTOs.Settings; + +/** + * The part of the OIDC configuration that is returned by the API without authentication + */ +public record OidcPublicConfigDto +{ + /// + /// Automatically redirect to the Oidc login screen + /// + public bool AutoLogin { get; set; } + /// + /// Disables password authentication for non-admin users + /// + public bool DisablePasswordAuthentication { get; set; } + /// + /// Name of your provider, used to display on the login screen + /// + /// Default to OpenID Connect + public string ProviderName { get; set; } = "OpenID Connect"; + public bool Enabled { get; set; } = false; +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 372042250..a19a1ed5b 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -92,6 +92,11 @@ public sealed record ServerSettingDto /// SMTP Configuration /// public SmtpConfigDto SmtpConfig { get; set; } + /// + /// OIDC Configuration + /// + public OidcConfigDto OidcConfig { get; set; } + /// /// The Date Kavita was first installed /// diff --git a/API/DTOs/Stats/V3/LibraryStatV3.cs b/API/DTOs/Stats/V3/LibraryStatV3.cs index 33ac86d2b..461792666 100644 --- a/API/DTOs/Stats/V3/LibraryStatV3.cs +++ b/API/DTOs/Stats/V3/LibraryStatV3.cs @@ -22,6 +22,10 @@ public sealed record LibraryStatV3 /// public bool CreateReadingListsFromMetadata { get; set; } /// + /// If the library has metadata turned on + /// + public bool EnabledMetadata { get; set; } + /// /// Type of the Library /// public LibraryType LibraryType { get; set; } diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs index 8ed3079f5..b19d173d9 100644 --- a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -131,6 +131,10 @@ public sealed record ServerInfoV3Dto /// Is this server using Kavita+ /// public bool ActiveKavitaPlusSubscription { get; set; } + /// + /// Is OIDC enabled + /// + public bool OidcEnabled { get; set; } #endregion #region Users diff --git a/API/DTOs/Stats/V3/UserStatV3.cs b/API/DTOs/Stats/V3/UserStatV3.cs index 450a2e409..a7aed206c 100644 --- a/API/DTOs/Stats/V3/UserStatV3.cs +++ b/API/DTOs/Stats/V3/UserStatV3.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.Data.Misc; +using API.Entities.Enums; using API.Entities.Enums.Device; namespace API.DTOs.Stats.V3; @@ -76,6 +77,10 @@ public sealed record UserStatV3 /// Roles for this user /// public ICollection Roles { get; set; } + /// + /// Who manages the user (OIDC, Kavita) + /// + public IdentityProvider IdentityProvider { get; set; } } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 88dc97a5d..09e5d0e59 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,6 +1,8 @@  -using System; +using System.Collections.Generic; using API.DTOs.Account; +using API.Entities; +using API.Entities.Enums; namespace API.DTOs; #nullable enable @@ -9,10 +11,13 @@ public sealed record UserDto { public string Username { get; init; } = null!; public string Email { get; init; } = null!; + public IList Roles { get; set; } = []; public string Token { get; set; } = null!; public string? RefreshToken { get; set; } public string? ApiKey { get; init; } public UserPreferencesDto? Preferences { get; set; } public AgeRestrictionDto? AgeRestriction { get; init; } public string KavitaVersion { get; set; } + /// + public IdentityProvider IdentityProvider { get; init; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 7d529b1da..b558d4989 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -300,6 +300,10 @@ public sealed class DataContext : IdentityDbContext JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) .HasColumnType("TEXT") .HasDefaultValue(new List()); + + builder.Entity() + .Property(user => user.IdentityProvider) + .HasDefaultValue(IdentityProvider.Kavita); } #nullable enable diff --git a/API/Data/Migrations/20250802103258_OpenIDConnect.Designer.cs b/API/Data/Migrations/20250802103258_OpenIDConnect.Designer.cs new file mode 100644 index 000000000..e49b83a9b --- /dev/null +++ b/API/Data/Migrations/20250802103258_OpenIDConnect.Designer.cs @@ -0,0 +1,3736 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250802103258_OpenIDConnect")] + partial class OpenIDConnect + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OidcId") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250802103258_OpenIDConnect.cs b/API/Data/Migrations/20250802103258_OpenIDConnect.cs new file mode 100644 index 000000000..0bad34851 --- /dev/null +++ b/API/Data/Migrations/20250802103258_OpenIDConnect.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class OpenIDConnect : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IdentityProvider", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "OidcId", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IdentityProvider", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "OidcId", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 356c67819..b662ab9f9 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -90,6 +90,11 @@ namespace API.Data.Migrations b.Property("HasRunScrobbleEventGeneration") .HasColumnType("INTEGER"); + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("LastActive") .HasColumnType("TEXT"); @@ -116,6 +121,9 @@ namespace API.Data.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("OidcId") + .HasColumnType("TEXT"); + b.Property("PasswordHash") .HasColumnType("TEXT"); @@ -3640,7 +3648,8 @@ namespace API.Data.Migrations b.Navigation("TableOfContents"); - b.Navigation("UserPreferences"); + b.Navigation("UserPreferences") + .IsRequired(); b.Navigation("UserRoles"); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 6437cfcfe..4a3f3f099 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -107,6 +107,13 @@ public interface IUserRepository Task> GetDashboardStreamsByIds(IList streamIds); Task> GetUserTokenInfo(); Task GetUserByDeviceEmail(string deviceEmail); + /// + /// Try getting a user by the id provided by OIDC + /// + /// + /// + /// + Task GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None); } public class UserRepository : IUserRepository @@ -557,6 +564,16 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(); } + public async Task GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None) + { + if (string.IsNullOrEmpty(oidcId)) return null; + + return await _context.AppUser + .Where(u => u.OidcId == oidcId) + .Includes(includes) + .FirstOrDefaultAsync(); + } + public async Task> GetAdminUsersAsync() { @@ -789,6 +806,7 @@ public class UserRepository : IUserRepository LastActiveUtc = u.LastActiveUtc, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), IsPending = !u.EmailConfirmed, + IdentityProvider = u.IdentityProvider, AgeRestriction = new AgeRestrictionDto() { AgeRating = u.AgeRestriction, @@ -800,7 +818,7 @@ public class UserRepository : IUserRepository Type = l.Type, LastScanned = l.LastScanned, Folders = l.Folders.Select(x => x.Path).ToList() - }).ToList() + }).ToList(), }) .AsSplitQuery() .AsNoTracking() diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index c08f80afa..e874e810b 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -5,9 +5,11 @@ using System.Globalization; using System.IO; using System.Linq; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; using API.Constants; using API.Data.Repositories; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.Theme; @@ -252,6 +254,7 @@ public static class Seed new() { Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty }, // Not used from DB, but DB is sync with appSettings.json + new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())}, new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, @@ -289,9 +292,29 @@ public static class Seed (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = Configuration.CacheSize + string.Empty; + await SetOidcSettingsFromDisk(context); + + await context.SaveChangesAsync(); } + public static async Task SetOidcSettingsFromDisk(DataContext context) + { + var oidcSettingEntry = await context.ServerSetting + .FirstOrDefaultAsync(setting => setting.Key == ServerSettingKey.OidcConfiguration); + + var storedOidcSettings = JsonSerializer.Deserialize(oidcSettingEntry!.Value)!; + + var diskOidcSettings = Configuration.OidcSettings; + + storedOidcSettings.Authority = diskOidcSettings.Authority; + storedOidcSettings.ClientId = diskOidcSettings.ClientId; + storedOidcSettings.Secret = diskOidcSettings.Secret; + storedOidcSettings.CustomScopes = diskOidcSettings.CustomScopes; + + oidcSettingEntry.Value = JsonSerializer.Serialize(storedOidcSettings); + } + public static async Task SeedMetadataSettings(DataContext context) { await context.Database.EnsureCreatedAsync(); diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 848636209..5fc0c94dd 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -1,6 +1,8 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using API.DTOs.Settings; using API.Entities.Enums; using API.Entities.Interfaces; using API.Entities.Scrobble; @@ -89,6 +91,15 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// Kavita+ only public DateTime ScrobbleEventGenerationRan { get; set; } + /// + /// The sub returned the by OIDC provider + /// + public string? OidcId { get; set; } + /// + /// The IdentityProvider for the user, default to + /// + public IdentityProvider IdentityProvider { get; set; } = IdentityProvider.Kavita; + /// /// A list of Series the user doesn't want scrobbling for diff --git a/API/Entities/Enums/IdentityProvider.cs b/API/Entities/Enums/IdentityProvider.cs new file mode 100644 index 000000000..8ae814882 --- /dev/null +++ b/API/Entities/Enums/IdentityProvider.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +/// +/// Who provides the identity of the user +/// +public enum IdentityProvider +{ + [Description("Kavita")] + Kavita = 0, + [Description("OpenID Connect")] + OpenIdConnect = 1, +} diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index b1050d553..e1d55c45f 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -197,4 +197,10 @@ public enum ServerSettingKey /// [Description("FirstInstallVersion")] FirstInstallVersion = 39, + /// + /// A Json object of type + /// + [Description("OidcConfiguration")] + OidcConfiguration = 40, + } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index bd4783f25..c0eddd272 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -4,12 +4,14 @@ using API.Data; using API.Helpers; using API.Services; using API.Services.Plus; +using API.Services.Store; using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.SignalR; using API.SignalR.Presence; using Kavita.Common; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -83,6 +85,8 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); @@ -106,6 +110,7 @@ public static class ApplicationServiceExtensions options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB options.CompactionPercentage = 0.1; // LRU compaction (10%) }); + services.AddSingleton(); services.AddSwaggerGen(g => { diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 2e86f8bbd..0227da696 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using API.Constants; using Kavita.Common; using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; @@ -8,6 +11,8 @@ namespace API.Extensions; public static class ClaimsPrincipalExtensions { private const string NotAuthenticatedMessage = "User is not authenticated"; + private const string EmailVerifiedClaimType = "email_verified"; + /// /// Get's the authenticated user's username /// @@ -26,4 +31,26 @@ public static class ClaimsPrincipalExtensions var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage); return int.Parse(userClaim.Value); } + + public static bool HasVerifiedEmail(this ClaimsPrincipal user) + { + var emailVerified = user.FindFirst(EmailVerifiedClaimType); + if (emailVerified == null) return false; + + if (!bool.TryParse(emailVerified.Value, out bool emailVerifiedValue) || !emailVerifiedValue) + { + return false; + } + + return true; + } + + public static IList GetClaimsWithPrefix(this ClaimsPrincipal claimsPrincipal, string claimType, string prefix) + { + return claimsPrincipal + .FindAll(claimType) + .Where(c => c.Value.StartsWith(prefix)) + .Select(c => c.Value.TrimPrefix(prefix)) + .ToList(); + } } diff --git a/API/Extensions/EnumExtensions.cs b/API/Extensions/EnumExtensions.cs new file mode 100644 index 000000000..63e28b8ab --- /dev/null +++ b/API/Extensions/EnumExtensions.cs @@ -0,0 +1,43 @@ +#nullable enable +using System; +using System.ComponentModel; +using System.Reflection; + +namespace API.Extensions; + +public static class EnumExtensions +{ + /// + /// Extension on Enum.TryParse which also tried matching on the description attribute + /// + /// if a match was found + /// First tries Enum.TryParse then fall back to the more expensive operation + public static bool TryParse(string? value, out TEnum result) where TEnum : struct, Enum + { + result = default; + + if (string.IsNullOrEmpty(value)) + { + return false; + } + + if (Enum.TryParse(value, out result)) + { + return true; + } + + foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var description = field.GetCustomAttribute()?.Description; + + if (!string.IsNullOrEmpty(description) && + string.Equals(description, value, StringComparison.OrdinalIgnoreCase)) + { + result = (TEnum)field.GetValue(null)!; + return true; + } + } + + return false; + } +} diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 9bc06bab4..af55981f2 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -6,6 +6,7 @@ using API.Data.Misc; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using Microsoft.AspNetCore.Identity; namespace API.Extensions; #nullable enable diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 9549e9a2c..c4b5bde3e 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,21 +1,43 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; using System.Text; using System.Threading.Tasks; using API.Constants; using API.Data; using API.Entities; +using API.Services; +using Kavita.Common; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; +using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext; +using TokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext; namespace API.Extensions; #nullable enable public static class IdentityServiceExtensions { - public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) + private const string DynamicHybrid = nameof(DynamicHybrid); + public const string OpenIdConnect = nameof(OpenIdConnect); + private const string LocalIdentity = nameof(LocalIdentity); + + private const string OidcCallback = "/signin-oidc"; + private const string OidcLogoutCallback = "/signout-callback-oidc"; + + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment environment) { services.Configure(options => { @@ -47,42 +69,264 @@ public static class IdentityServiceExtensions .AddRoleValidator>() .AddEntityFrameworkStores(); + var oidcSettings = Configuration.OidcSettings; - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + var auth = services.AddAuthentication(DynamicHybrid) + .AddPolicyScheme(DynamicHybrid, JwtBearerDefaults.AuthenticationScheme, options => { - options.TokenValidationParameters = new TokenValidationParameters() - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)), - ValidateIssuer = false, - ValidateAudience = false, - ValidIssuer = "Kavita" - }; + var enabled = oidcSettings.Enabled; - options.Events = new JwtBearerEvents() + options.ForwardDefaultSelector = ctx => { - OnMessageReceived = context => + if (!enabled) return LocalIdentity; + + if (ctx.Request.Path.StartsWithSegments(OidcCallback) || + ctx.Request.Path.StartsWithSegments(OidcLogoutCallback)) { - var accessToken = context.Request.Query["access_token"]; - var path = context.HttpContext.Request.Path; - // Only use query string based token on SignalR hubs - if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) - { - context.Token = accessToken; - } - - return Task.CompletedTask; + return OpenIdConnect; } + + if (ctx.Request.Headers.Authorization.Count != 0) + { + return LocalIdentity; + } + + if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName)) + { + return OpenIdConnect; + } + + return LocalIdentity; }; + }); - services.AddAuthorization(opt => + + + if (oidcSettings.Enabled) { - opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); - opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); - opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); + services.SetupOpenIdConnectAuthentication(auth, oidcSettings, environment); + } + + auth.AddJwtBearer(LocalIdentity, options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)), + ValidateIssuer = false, + ValidateAudience = false, + ValidIssuer = "Kavita", + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = SetTokenFromQuery, + }; }); + + services.AddAuthorizationBuilder() + .AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)) + .AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)) + .AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); + return services; } + + private static void SetupOpenIdConnectAuthentication(this IServiceCollection services, AuthenticationBuilder auth, + Configuration.OpenIdConnectSettings settings, IWebHostEnvironment environment) + { + var isDevelopment = environment.IsEnvironment(Environments.Development); + var baseUrl = Configuration.BaseUrl; + + var apiPrefix = baseUrl + "api"; + var hubsPrefix = baseUrl + "hubs"; + + services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme).Configure((options, store) => + { + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.SlidingExpiration = true; + + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.MaxAge = TimeSpan.FromDays(7); + options.SessionStore = store; + + if (isDevelopment) + { + options.Cookie.Domain = null; + } + + options.Events = new CookieAuthenticationEvents + { + OnValidatePrincipal = async ctx => + { + var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); + var user = await oidcService.RefreshCookieToken(ctx); + + if (user != null) + { + var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user!, false); + ctx.ReplacePrincipal(new ClaimsPrincipal(new ClaimsIdentity(claims, ctx.Scheme.Name))); + } + }, + OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }, + }; + }); + + auth.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme); + auth.AddOpenIdConnect(OpenIdConnect, options => + { + options.Authority = settings.Authority; + options.ClientId = settings.ClientId; + options.ClientSecret = settings.Secret; + options.RequireHttpsMetadata = options.Authority.StartsWith("https://"); + + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.ResponseType = OpenIdConnectResponseType.Code; + options.CallbackPath = OidcCallback; + options.SignedOutCallbackPath = OidcLogoutCallback; + + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("offline_access"); + options.Scope.Add("roles"); + options.Scope.Add("email"); + + foreach (var customScope in settings.CustomScopes) + { + options.Scope.Add(customScope); + } + + options.Events = new OpenIdConnectEvents + { + OnTokenValidated = OidcClaimsPrincipalConverter, + OnAuthenticationFailed = ctx => + { + ctx.Response.Redirect(baseUrl + "login?skipAutoLogin=true&error=" + Uri.EscapeDataString(ctx.Exception.Message)); + ctx.HandleResponse(); + + return Task.CompletedTask; + }, + OnRedirectToIdentityProviderForSignOut = ctx => + { + if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.PostLogoutRedirectUri)) + { + ctx.ProtocolMessage.PostLogoutRedirectUri = ctx.ProtocolMessage.PostLogoutRedirectUri.Replace("http://", "https://"); + } + + return Task.CompletedTask; + }, + OnRedirectToIdentityProvider = ctx => + { + // Intercept redirects on API requests and instead return 401 + // These redirects are auto login when .NET finds a cookie that it can't match inside the cookie store. I.e. after a restart + if (ctx.Request.Path.StartsWithSegments(apiPrefix) || ctx.Request.Path.StartsWithSegments(hubsPrefix)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + ctx.HandleResponse(); + return Task.CompletedTask; + } + + if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.RedirectUri)) + { + ctx.ProtocolMessage.RedirectUri = ctx.ProtocolMessage.RedirectUri.Replace("http://", "https://"); + } + + return Task.CompletedTask; + }, + }; + }); + } + + /// + /// Called after the redirect from the OIDC provider, tries matching the user and update the principal + /// to have the correct claims and properties. This is required to later auto refresh; and ensure .NET knows which + /// Kavita roles the user has + /// + /// + private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx) + { + if (ctx.Principal == null) return; + + var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); + var user = await oidcService.LoginOrCreate(ctx.Request, ctx.Principal); + if (user == null) + { + throw new KavitaException("errors.oidc.no-account"); + } + + var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user); + var tokens = CopyOidcTokens(ctx); + + var identity = new ClaimsIdentity(claims, ctx.Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + ctx.Properties ??= new AuthenticationProperties(); + ctx.Properties.StoreTokens(tokens); + + ctx.HttpContext.User = principal; + ctx.Principal = principal; + + ctx.Success(); + } + + /// + /// Copy tokens returned by the OIDC provider that we require later + /// + /// + /// + private static List CopyOidcTokens(TokenValidatedContext ctx) + { + if (ctx.TokenEndpointResponse == null) + { + return []; + } + + var tokens = new List(); + + if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken)) + { + tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken }); + } + else + { + var logger = ctx.HttpContext.RequestServices.GetRequiredService>(); + logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user"); + } + + if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken)) + { + tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken }); + } + + if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn)) + { + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn)); + tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") }); + } + + return tokens; + } + + private static Task SetTokenFromQuery(MessageReceivedContext context) + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + + // Only use query string based token on SignalR hubs + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } } diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 28419921a..fc005b06f 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -52,4 +52,33 @@ public static class StringExtensions { return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture); } + + public static string TrimPrefix(this string? value, string prefix) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + + if (!value.StartsWith(prefix)) return value; + + return value.Substring(prefix.Length); + } + + /// + /// Censor the input string by removing all but the first and last char. + /// + /// + /// + /// If the input is an email (contains @), the domain will remain untouched + public static string Censor(this string? input) + { + if (string.IsNullOrWhiteSpace(input)) return input ?? string.Empty; + + var atIdx = input.IndexOf('@'); + if (atIdx == -1) + { + return $"{input[0]}{new string('*', input.Length - 1)}"; + } + + return input[0] + new string('*', atIdx - 1) + input[atIdx..]; + } + } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index bb7511c64..e4a9438c3 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -386,7 +386,6 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); - - + CreateMap(); } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 7adb5228f..d8bc6e802 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Text.Json; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -129,6 +130,9 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.FirstInstallVersion: destination.FirstInstallVersion = row.Value; break; + case ServerSettingKey.OidcConfiguration: + destination.OidcConfig = JsonSerializer.Deserialize(row.Value)!; + break; case ServerSettingKey.LicenseKey: case ServerSettingKey.EnableAuthentication: case ServerSettingKey.EmailServiceUrl: diff --git a/API/I18N/en.json b/API/I18N/en.json index d3cd1ecd3..09a990d77 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -2,6 +2,7 @@ "confirm-email": "You must confirm your email first", "locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.", "disabled-account": "Your account is disabled. Contact the server admin.", + "password-authentication-disabled": "Password authentication has been disabled, login via OpenID Connect", "register-user": "Something went wrong when registering user", "validate-email": "There was an issue validating your email: {0}", "confirm-token-gen": "There was an issue generating a confirmation token", @@ -17,6 +18,8 @@ "generate-token": "There was an issue generating a confirmation email token. See logs", "age-restriction-update": "There was an error updating the age restriction", "no-user": "User does not exist", + "oidc-managed": "Users managed by OIDC cannot be edited.", + "cannot-change-identity-provider-original-user": "Identity Provider of the original admin account cannot be changed", "username-taken": "Username already taken", "email-taken": "Email already in use", "user-already-confirmed": "User is already confirmed", @@ -42,6 +45,7 @@ "email-not-enabled": "Email is not enabled on this server. You cannot perform this action.", "account-email-invalid": "The email on file for the admin account is not a valid email. Cannot send test email.", "email-settings-invalid": "Email settings missing information. Ensure all email settings are saved.", + "oidc-invalid-authority": "OIDC authority is invalid", "chapter-doesnt-exist": "Chapter does not exist", "file-missing": "File was not found in book", diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 74b6709fa..8cef587e7 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -1,19 +1,22 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; -using System.Web; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs.Account; using API.Entities; +using API.Entities.Enums; using API.Errors; using API.Extensions; +using API.Helpers.Builders; +using API.SignalR; +using AutoMapper; using Kavita.Common; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Services; @@ -24,25 +27,56 @@ public interface IAccountService { Task> ChangeUserPassword(AppUser user, string newPassword); Task> ValidatePassword(AppUser user, string password); - Task> ValidateUsername(string username); + Task> ValidateUsername(string? username); Task> ValidateEmail(string email); Task HasBookmarkPermission(AppUser? user); Task HasDownloadPermission(AppUser? user); Task CanChangeAgeRestriction(AppUser? user); + + /// + /// + /// + /// The user who is changing the identity + /// the user being changed + /// the provider being changed to + /// If true, user should not be updated by kavita (anymore) + /// Throws if invalid actions are being performed + Task ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider); + /// + /// Removes access to all libraries, then grant access to all given libraries or all libraries if the user is admin. + /// Creates side nav streams as well + /// + /// + /// + /// + /// + /// Ensure that the users SideNavStreams are loaded + /// Does NOT commit + Task UpdateLibrariesForUser(AppUser user, IList librariesIds, bool hasAdminRole); + Task> UpdateRolesForUser(AppUser user, IList roles); + void AddDefaultStreamsToUser(AppUser user); + Task AddDefaultReadingProfileToUser(AppUser user); } -public class AccountService : IAccountService +public partial class AccountService : IAccountService { + private readonly ILocalizationService _localizationService; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; + public static readonly Regex AllowedUsernameRegex = AllowedUsernameRegexAttr(); - public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) + + public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork, + IMapper mapper, ILocalizationService localizationService) { + _localizationService = localizationService; _userManager = userManager; _logger = logger; _unitOfWork = unitOfWork; + _mapper = mapper; } public async Task> ChangeUserPassword(AppUser user, string newPassword) @@ -77,8 +111,13 @@ public class AccountService : IAccountService return Array.Empty(); } - public async Task> ValidateUsername(string username) + public async Task> ValidateUsername(string? username) { + if (string.IsNullOrWhiteSpace(username) || !AllowedUsernameRegex.IsMatch(username)) + { + return [new ApiException(400, "Invalid username")]; + } + // Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535 if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null && x.NormalizedUserName == username.ToUpper())) @@ -143,4 +182,113 @@ public class AccountService : IAccountService return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } + + public async Task ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider) + { + var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + if (user.Id == defaultAdminUser.Id) + { + throw new KavitaException(await _localizationService.Translate(actingUserId, "cannot-change-identity-provider-original-user")); + } + + // Allow changes if users aren't being synced + var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + if (!oidcSettings.SyncUserSettings) + { + user.IdentityProvider = identityProvider; + await _unitOfWork.CommitAsync(); + return false; + } + + // Don't allow changes to the user if they're managed by oidc, and their identity provider isn't being changed to something else + if (user.IdentityProvider == IdentityProvider.OpenIdConnect && identityProvider == IdentityProvider.OpenIdConnect) + { + throw new KavitaException(await _localizationService.Translate(actingUserId, "oidc-managed")); + } + + user.IdentityProvider = identityProvider; + await _unitOfWork.CommitAsync(); + return user.IdentityProvider == IdentityProvider.OpenIdConnect; + } + + public async Task UpdateLibrariesForUser(AppUser user, IList librariesIds, bool hasAdminRole) + { + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList(); + var currentLibrary = allLibraries.Where(l => l.AppUsers.Contains(user)).ToList(); + + List libraries; + if (hasAdminRole) + { + _logger.LogDebug("{UserId} is admin. Granting access to all libraries", user.Id); + libraries = allLibraries; + } + else + { + libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList(); + } + + var toRemove = currentLibrary.Except(libraries); + var toAdd = libraries.Except(currentLibrary); + + foreach (var lib in toRemove) + { + lib.AppUsers ??= []; + lib.AppUsers.Remove(user); + user.RemoveSideNavFromLibrary(lib); + } + + foreach (var lib in toAdd) + { + lib.AppUsers ??= []; + lib.AppUsers.Add(user); + user.CreateSideNavFromLibrary(lib); + } + } + + public async Task> UpdateRolesForUser(AppUser user, IList roles) + { + var existingRoles = await _userManager.GetRolesAsync(user); + var hasAdminRole = roles.Contains(PolicyConstants.AdminRole); + if (!hasAdminRole) + { + roles.Add(PolicyConstants.PlebRole); + } + + if (existingRoles.Except(roles).Any() || roles.Except(existingRoles).Any()) + { + var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles); + if (!roleResult.Succeeded) return roleResult.Errors; + + roleResult = await _userManager.AddToRolesAsync(user, roles); + if (!roleResult.Succeeded) return roleResult.Errors; + } + + return []; + } + + public void AddDefaultStreamsToUser(AppUser user) + { + foreach (var newStream in Seed.DefaultStreams.Select(_mapper.Map)) + { + user.DashboardStreams.Add(newStream); + } + + foreach (var stream in Seed.DefaultSideNavStreams.Select(_mapper.Map)) + { + user.SideNavStreams.Add(stream); + } + } + + public async Task AddDefaultReadingProfileToUser(AppUser user) + { + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Default Profile") + .WithKind(ReadingProfileKind.Default) + .Build(); + _unitOfWork.AppUserReadingProfileRepository.Add(profile); + await _unitOfWork.CommitAsync(); + } + + [GeneratedRegex(@"^[a-zA-Z0-9\-._@+/]*$")] + private static partial Regex AllowedUsernameRegexAttr(); } diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs new file mode 100644 index 000000000..e933018b2 --- /dev/null +++ b/API/Services/OidcService.cs @@ -0,0 +1,662 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Email; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers.Builders; +using Hangfire; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace API.Services; + +public interface IOidcService +{ + /// + /// Returns the user authenticated with OpenID Connect + /// + /// + /// + /// + /// if any requirements aren't met + Task LoginOrCreate(HttpRequest request, ClaimsPrincipal principal); + /// + /// Refresh the token inside the cookie when it's close to expiring. And sync the user + /// + /// + /// + /// If the token is refreshed successfully, updates the last active time of the suer + Task RefreshCookieToken(CookieValidatePrincipalContext ctx); + /// + /// Remove from all users + /// + /// + Task ClearOidcIds(); +} + +public class OidcService(ILogger logger, UserManager userManager, + IUnitOfWork unitOfWork, IAccountService accountService, IEmailService emailService): IOidcService +{ + public const string LibraryAccessPrefix = "library-"; + public const string AgeRestrictionPrefix = "age-restriction-"; + public const string IncludeUnknowns = "include-unknowns"; + public const string RefreshToken = "refresh_token"; + public const string IdToken = "id_token"; + public const string ExpiresAt = "expires_at"; + /// The name of the Auth Cookie set by .NET + public const string CookieName = ".AspNetCore.Cookies"; + + private OpenIdConnectConfiguration? _discoveryDocument; + private static readonly ConcurrentDictionary RefreshInProgress = new(); + private static readonly ConcurrentDictionary LastFailedRefresh = new(); + + public async Task LoginOrCreate(HttpRequest request, ClaimsPrincipal principal) + { + var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + + var oidcId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(oidcId)) + { + throw new KavitaException("errors.oidc.missing-external-id"); + } + + var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences); + if (user != null) return user; + + var email = principal.FindFirstValue(ClaimTypes.Email); + if (string.IsNullOrEmpty(email)) + { + throw new KavitaException("errors.oidc.missing-email"); + } + + if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail()) + { + throw new KavitaException("errors.oidc.email-not-verified"); + } + + + user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); + if (user != null) + { + // Don't allow taking over accounts + // This could happen if the user changes their email in OIDC, and then someone else uses the old one + if (!string.IsNullOrEmpty(user.OidcId)) + { + throw new KavitaException("errors.oidc.email-in-use"); + } + + logger.LogDebug("User {UserName} has matched on email to {OidcId}", user.Id, oidcId); + user.OidcId = oidcId; + await unitOfWork.CommitAsync(); + + return user; + } + + return await CreateNewAccount(request, principal, settings, oidcId); + } + + public async Task RefreshCookieToken(CookieValidatePrincipalContext ctx) + { + if (ctx.Principal == null) return null; + + var user = await unitOfWork.UserRepository.GetUserByIdAsync(ctx.Principal.GetUserId()) ?? throw new UnauthorizedAccessException(); + var key = ctx.Principal.GetUsername(); + + var refreshToken = ctx.Properties.GetTokenValue(RefreshToken); + if (string.IsNullOrEmpty(refreshToken)) return user; + + var expiresAt = ctx.Properties.GetTokenValue(ExpiresAt); + if (string.IsNullOrEmpty(expiresAt)) return user; + + // Do not spam refresh if it failed + if (LastFailedRefresh.TryGetValue(key, out var time) && time.AddMinutes(30) < DateTimeOffset.UtcNow) return user; + + var tokenExpiry = DateTimeOffset.ParseExact(expiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + if (tokenExpiry >= DateTimeOffset.UtcNow.AddSeconds(30)) return user; + + // Ensure we're not refreshing twice + if (!RefreshInProgress.TryAdd(key, true)) return user; + + try + { + var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + + var tokenResponse = await RefreshTokenAsync(settings, refreshToken); + if (!string.IsNullOrEmpty(tokenResponse.Error)) + { + logger.LogTrace("Failed to refresh token : {Error} - {Description}", tokenResponse.Error, tokenResponse.ErrorDescription); + LastFailedRefresh.TryAdd(key, DateTimeOffset.UtcNow); + return user; + } + + var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(tokenResponse.ExpiresIn)); + ctx.Properties.UpdateTokenValue(ExpiresAt, newExpiresAt.ToString("o")); + ctx.Properties.UpdateTokenValue(RefreshToken, tokenResponse.RefreshToken); + ctx.Properties.UpdateTokenValue(IdToken, tokenResponse.IdToken); + ctx.ShouldRenew = true; + + try + { + user.UpdateLastActive(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName); + } + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + + if (string.IsNullOrEmpty(tokenResponse.IdToken)) + { + logger.LogTrace("The OIDC provider did not return an id token in the refresh response, continuous sync is not supported"); + return user; + } + + await SyncUserSettings(ctx, settings, tokenResponse.IdToken, user); + logger.LogTrace("Automatically refreshed token for user {UserId}", ctx.Principal?.GetUserId()); + } + finally + { + RefreshInProgress.TryRemove(key, out _); + LastFailedRefresh.TryRemove(key, out _); + } + + return user; + } + + public async Task ClearOidcIds() + { + var users = await unitOfWork.UserRepository.GetAllUsersAsync(); + foreach (var user in users) + { + user.OidcId = null; + } + + await unitOfWork.CommitAsync(); + } + + /// + /// Tries to construct a new account from the OIDC Principal, may fail if required conditions aren't met + /// + /// + /// + /// + /// + /// + /// + private async Task CreateNewAccount(HttpRequest request, ClaimsPrincipal principal, OidcConfigDto settings, string oidcId) + { + var accessRoles = principal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix) + .Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList(); + if (settings.SyncUserSettings && accessRoles.Count == 0) + { + throw new KavitaException("errors.oidc.role-not-assigned"); + } + + AppUser? user; + try + { + user = await NewUserFromOpenIdConnect(request, settings, principal, oidcId); + } + catch (KavitaException e) + { + throw; + } + catch (Exception e) + { + logger.LogError(e, "An error occured creating a new user"); + throw new KavitaException("errors.oidc.creating-user"); + } + + if (user == null) return null; + + var roles = await userManager.GetRolesAsync(user); + if (roles.Count == 0 || (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole))) + { + throw new KavitaException("errors.oidc.disabled-account"); + } + + return user; + } + + /// + /// Find the best available name from claims + /// + /// + /// Also return if the claim is equal to this value + /// + public async Task FindBestAvailableName(ClaimsPrincipal claimsPrincipal, string? orEqualTo = null) + { + var nameCandidates = new[] + { + claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername), + claimsPrincipal.FindFirstValue(ClaimTypes.Name), + claimsPrincipal.FindFirstValue(ClaimTypes.GivenName), + claimsPrincipal.FindFirstValue(ClaimTypes.Surname) + }; + + foreach (var name in nameCandidates.Where(n => !string.IsNullOrEmpty(n))) + { + if (name == orEqualTo || await IsNameAvailable(name)) + { + return name; + } + } + + return null; + } + + private async Task IsNameAvailable(string? name) + { + return !(await accountService.ValidateUsername(name)).Any(); + } + + private async Task NewUserFromOpenIdConnect(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId) + { + if (!settings.ProvisionAccounts) return null; + + var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); + if (string.IsNullOrWhiteSpace(emailClaim?.Value)) return null; + + var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value; + logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name.Censor(), externalId); + + var user = new AppUserBuilder(name, emailClaim.Value, + await unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build(); + + var res = await userManager.CreateAsync(user); + if (!res.Succeeded) + { + logger.LogError("Failed to create new user from OIDC: {Errors}", + res.Errors.Select(x => x.Description).ToList()); + throw new KavitaException("errors.oidc.creating-user"); + } + + if (claimsPrincipal.HasVerifiedEmail()) + { + var token = await userManager.GenerateEmailConfirmationTokenAsync(user); + await userManager.ConfirmEmailAsync(user, token); + } + + user.OidcId = externalId; + user.IdentityProvider = IdentityProvider.OpenIdConnect; + + accountService.AddDefaultStreamsToUser(user); + await accountService.AddDefaultReadingProfileToUser(user); + + await SyncUserSettings(request, settings, claimsPrincipal, user); + await SetDefaults(settings, user); + + await unitOfWork.CommitAsync(); + + return user; + } + + /// + /// Assign configured defaults (libraries, age ratings, roles) to the newly created user + /// + private async Task SetDefaults(OidcConfigDto settings, AppUser user) + { + if (settings.SyncUserSettings) return; + + logger.LogDebug("Assigning defaults to newly created user; Roles: {Roles}, Libraries: {Libraries}, AgeRating: {AgeRating}, IncludeUnknowns: {IncludeUnknowns}", + settings.DefaultRoles, settings.DefaultLibraries, settings.DefaultAgeRestriction, settings.DefaultIncludeUnknowns); + + // Assign roles + var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles); + if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); + + // Assign libraries + await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole)); + + // Assign age rating + user.AgeRestriction = settings.DefaultAgeRestriction; + user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns; + + await unitOfWork.CommitAsync(); + } + + private async Task SyncUserSettings(CookieValidatePrincipalContext ctx, OidcConfigDto settings, string idToken, AppUser user) + { + if (!settings.SyncUserSettings) return; + + try + { + var newPrincipal = await ParseIdToken(settings, idToken); + await SyncUserSettings(ctx.HttpContext.Request, settings, newPrincipal, user); + } + catch (KavitaException ex) + { + logger.LogError(ex, "Failed to sync user after token refresh"); + throw new UnauthorizedAccessException(ex.Message); + } + } + + /// + /// Updates roles, library access and age rating restriction. Will not modify the default admin + /// + /// + /// + /// + /// + public async Task SyncUserSettings(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + { + if (!settings.SyncUserSettings) return; + + // Never sync the default user + var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); + if (defaultAdminUser.Id == user.Id) return; + + logger.LogDebug("Syncing user {UserId} from OIDC", user.Id); + try + { + + await SyncEmail(request, settings, claimsPrincipal, user); + await SyncUsername(claimsPrincipal, user); + await SyncRoles(settings, claimsPrincipal, user); + await SyncLibraries(settings, claimsPrincipal, user); + await SyncAgeRestriction(settings, claimsPrincipal, user); + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to sync user {UserId} from OIDC", user.Id); + await unitOfWork.RollbackAsync(); + throw new KavitaException("errors.oidc.syncing-user", ex); + } + } + + private async Task SyncEmail(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + { + var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email); + if (string.IsNullOrEmpty(email) || user.Email == email) return; + + if (settings.RequireVerifiedEmail && !claimsPrincipal.HasVerifiedEmail()) + { + throw new KavitaException("errors.oidc.email-not-verified"); + } + + // Ensure no other user uses this email + var other = await userManager.FindByEmailAsync(email); + if (other != null) + { + throw new KavitaException("errors.oidc.email-in-use"); + } + + // The email is verified, we can go ahead and change & confirm it + if (claimsPrincipal.HasVerifiedEmail()) + { + var res = await userManager.SetEmailAsync(user, email); + if (!res.Succeeded) + { + logger.LogError("Failed to update email for user {UserId} from OIDC {Errors}", user.Id, res.Errors.Select(x => x.Description).ToList()); + throw new KavitaException("errors.oidc.failed-to-update-email"); + } + + user.EmailConfirmed = true; + await userManager.UpdateAsync(user); + return; + } + + var token = await userManager.GenerateEmailConfirmationTokenAsync(user); + var isValidEmailAddress = !string.IsNullOrEmpty(user.Email) && emailService.IsValidEmail(user.Email); + var isEmailSetup = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup(); + var shouldEmailUser = isEmailSetup || !isValidEmailAddress; + + user.EmailConfirmed = !shouldEmailUser; + user.ConfirmationToken = token; + await userManager.UpdateAsync(user); + + var emailLink = await emailService.GenerateEmailLink(request, user.ConfirmationToken, "confirm-email-update", email); + logger.LogCritical("[Update Email]: Automatic email update after OIDC sync, email Link for {UserId}: {Link}", user.Id, emailLink); + + if (!shouldEmailUser) + { + logger.LogInformation("Cannot email admin, email not setup or admin email invalid"); + return; + } + + if (!isValidEmailAddress) + { + logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email.Censor()); + return; + } + + try + { + var invitingUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); + BackgroundJob.Enqueue(() => emailService.SendEmailChangeEmail(new ConfirmationEmailDto() + { + EmailAddress = string.IsNullOrEmpty(user.Email) ? email : user.Email, + InstallId = BuildInfo.Version.ToString(), + InvitingUser = invitingUser.UserName, + ServerConfirmationLink = emailLink, + })); + } + catch (Exception) + { + /* Swallow exception */ + } + + } + + private async Task SyncUsername(ClaimsPrincipal claimsPrincipal, AppUser user) + { + var bestName = await FindBestAvailableName(claimsPrincipal, user.UserName); + if (bestName == null || bestName == user.UserName) return; + + var res = await userManager.SetUserNameAsync(user, bestName); + if (!res.Succeeded) + { + logger.LogError("Failed to update username for user {UserId} to {NewUserName} from OIDC {Errors}", user.Id, + bestName.Censor(), res.Errors.Select(x => x.Description).ToList()); + throw new KavitaException("errors.oidc.failed-to-update-username"); + } + } + + private async Task SyncRoles(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + { + var roles = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix) + .Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList(); + logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles); + + var errors = (await accountService.UpdateRolesForUser(user, roles)).ToList(); + if (errors.Any()) + { + logger.LogError("Failed to sync roles {Errors}", errors.Select(x => x.Description).ToList()); + throw new KavitaException("errors.oidc.syncing-user"); + } + } + + private async Task SyncLibraries(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + { + var libraryAccessPrefix = settings.RolesPrefix + LibraryAccessPrefix; + var libraryAccess = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, libraryAccessPrefix); + + logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess); + + var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + // Distinct to ensure each library (id) is only present once + var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).Distinct().ToList(); + + var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); + } + + private async Task SyncAgeRestriction(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + { + if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) + { + logger.LogDebug("User {UserId} is admin, granting access to all age ratings", user.Id); + user.AgeRestriction = AgeRating.NotApplicable; + user.AgeRestrictionIncludeUnknowns = true; + return; + } + + var ageRatingPrefix = settings.RolesPrefix + AgeRestrictionPrefix; + var ageRatings = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, ageRatingPrefix); + logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings); + + if (ageRatings.Count == 0 || (ageRatings.Count == 1 && ageRatings.Contains(IncludeUnknowns))) + { + logger.LogDebug("No age restriction found in roles, setting to NotApplicable and Include Unknowns: {IncludeUnknowns}", settings.DefaultIncludeUnknowns); + + user.AgeRestriction = AgeRating.NotApplicable; + user.AgeRestrictionIncludeUnknowns = true; + return; + } + + var highestAgeRestriction = AgeRating.NotApplicable; + + foreach (var ar in ageRatings) + { + if (!EnumExtensions.TryParse(ar, out AgeRating ageRating)) + { + logger.LogDebug("Age Restriction role configured that failed to map to a known age rating: {RoleName}", AgeRestrictionPrefix+ar); + continue; + } + + if (ageRating > highestAgeRestriction) + { + highestAgeRestriction = ageRating; + } + } + + user.AgeRestriction = highestAgeRestriction; + user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns); + + logger.LogDebug("Synced age restriction for user {UserId}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}", + user.Id, user.AgeRestriction, user.AgeRestrictionIncludeUnknowns); + } + + /// + /// Loads the discovery document if not already loaded, then refreshed the tokens for the user + /// + /// + /// + /// + /// + private async Task RefreshTokenAsync(OidcConfigDto dto, string refreshToken) + { + + _discoveryDocument ??= await LoadOidcConfiguration(dto.Authority); + + var msg = new + { + grant_type = RefreshToken, + refresh_token = refreshToken, + client_id = dto.ClientId, + client_secret = dto.Secret, + }; + + var json = await _discoveryDocument.TokenEndpoint + .AllowAnyHttpStatus() + .PostUrlEncodedAsync(msg) + .ReceiveString(); + + return new OpenIdConnectMessage(json); + } + + /// + /// Loads the discovery document if not already loaded, then parses the given id token securely + /// + /// + /// + /// + /// + private async Task ParseIdToken(OidcConfigDto dto, string idToken) + { + _discoveryDocument ??= await LoadOidcConfiguration(dto.Authority); + var tokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = _discoveryDocument.Issuer, + ValidAudience = dto.ClientId, + IssuerSigningKeys = _discoveryDocument.SigningKeys, + ValidateIssuerSigningKey = true, + }; + + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(idToken, tokenValidationParameters, out _); + + return principal; + } + + /// + /// Loads OpenIdConnectConfiguration, includes + /// + /// + /// + private static async Task LoadOidcConfiguration(string authority) + { + var hasTrailingSlash = authority.EndsWith('/'); + var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration"; + + var manager = new ConfigurationManager( + url, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") } + ); + + return await manager.GetConfigurationAsync(); + } + + /// + /// Return a list of claims in the same way the NativeJWT token would map them. + /// Optionally include original claims if the claims are needed later in the pipeline + /// + /// + /// + /// + /// + /// + public static async Task> ConstructNewClaimsList(IServiceProvider services, ClaimsPrincipal? principal, AppUser user, bool includeOriginalClaims = true) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty), + new(ClaimTypes.Name, user.UserName ?? string.Empty), + }; + + var userManager = services.GetRequiredService>(); + var roles = await userManager.GetRolesAsync(user); + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + if (includeOriginalClaims) + { + claims.AddRange(principal?.Claims ?? []); + } + + return claims; + } + +} diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 227d777ed..0cf07c7a0 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Security.Claims; +using System.Text.Json; using System.Threading.Tasks; using API.Data; using API.DTOs; @@ -13,13 +15,13 @@ using API.Entities.MetadataMatching; using API.Extensions; using API.Logging; using API.Services.Tasks.Scanner; +using Flurl.Http; using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using SharpCompress.Common; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace API.Services; @@ -35,6 +37,12 @@ public interface ISettingsService /// Task ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings); Task UpdateSettings(ServerSettingDto updateSettingsDto); + /// + /// Check if the server can reach the authority at the given uri + /// + /// + /// + Task IsValidAuthority(string authority); } @@ -45,16 +53,18 @@ public class SettingsService : ISettingsService private readonly ILibraryWatcher _libraryWatcher; private readonly ITaskScheduler _taskScheduler; private readonly ILogger _logger; + private readonly IOidcService _oidcService; public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, - ILogger logger) + ILogger logger, IOidcService oidcService) { _unitOfWork = unitOfWork; _directoryService = directoryService; _libraryWatcher = libraryWatcher; _taskScheduler = taskScheduler; _logger = logger; + _oidcService = oidcService; } /// @@ -292,6 +302,7 @@ public class SettingsService : ISettingsService } var updateTask = false; + var updatedOidcSettings = false; foreach (var setting in currentSettings) { if (setting.Key == ServerSettingKey.OnDeckProgressDays && @@ -329,7 +340,7 @@ public class SettingsService : ISettingsService updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); UpdateEmailSettings(setting, updateSettingsDto); - + updatedOidcSettings = await UpdateOidcSettings(setting, updateSettingsDto) || updatedOidcSettings; if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) @@ -481,6 +492,17 @@ public class SettingsService : ISettingsService BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); } + if (updatedOidcSettings) + { + Configuration.OidcSettings = new Configuration.OpenIdConnectSettings + { + Authority = updateSettingsDto.OidcConfig.Authority, + ClientId = updateSettingsDto.OidcConfig.ClientId, + Secret = updateSettingsDto.OidcConfig.Secret, + CustomScopes = updateSettingsDto.OidcConfig.CustomScopes, + }; + } + if (updateSettingsDto.EnableFolderWatching) { BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); @@ -503,6 +525,29 @@ public class SettingsService : ISettingsService return updateSettingsDto; } + public async Task IsValidAuthority(string authority) + { + if (string.IsNullOrEmpty(authority)) + { + return false; + } + + try + { + var hasTrailingSlash = authority.EndsWith('/'); + var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration"; + + var json = await url.GetStringAsync(); + var config = OpenIdConnectConfiguration.Create(json); + return config.Issuer == authority; + } + catch (Exception e) + { + _logger.LogDebug(e, "OpenIdConfiguration failed: {Reason}", e.Message); + return false; + } + } + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) { _directoryService.ExistOrCreate(bookmarkDirectory); @@ -536,6 +581,45 @@ public class SettingsService : ISettingsService return false; } + /// + /// Updates oidc settings and return true if a change was made + /// + /// + /// + /// + /// Does not commit any changes + /// If the authority is invalid + private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key != ServerSettingKey.OidcConfiguration) return false; + + if (updateSettingsDto.OidcConfig.RolesClaim.Trim() == string.Empty) + { + updateSettingsDto.OidcConfig.RolesClaim = ClaimTypes.Role; + } + + var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); + if (setting.Value == newValue) return false; + + var currentConfig = JsonSerializer.Deserialize(setting.Value)!; + + if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority) + { + if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty)) + { + throw new KavitaException("oidc-invalid-authority"); + } + + _logger.LogWarning("OIDC Authority is changing, clearing all external ids"); + await _oidcService.ClearOidcIds(); + } + + setting.Value = newValue; + _unitOfWork.SettingsRepository.Update(setting); + + return true; + } + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) { if (setting.Key == ServerSettingKey.EmailHost && diff --git a/API/Services/Store/CustomTicketStore.cs b/API/Services/Store/CustomTicketStore.cs new file mode 100644 index 000000000..91696852d --- /dev/null +++ b/API/Services/Store/CustomTicketStore.cs @@ -0,0 +1,59 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Caching.Memory; + +namespace API.Services.Store; + +public class CustomTicketStore(IMemoryCache cache): ITicketStore +{ + + public async Task StoreAsync(AuthenticationTicket ticket) + { + // Note: It might not be needed to make this cryptographic random, but better safe than sorry + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + var key = Convert.ToBase64String(bytes); + + await RenewAsync(key, ticket); + + return key; + } + + public Task RenewAsync(string key, AuthenticationTicket ticket) + { + var options = new MemoryCacheEntryOptions + { + Priority = CacheItemPriority.NeverRemove, + Size = 1, + }; + + var expiresUtc = ticket.Properties.ExpiresUtc; + if (expiresUtc.HasValue) + { + options.AbsoluteExpiration = expiresUtc.Value; + } + else + { + options.SlidingExpiration = TimeSpan.FromDays(7); + } + + cache.Set(key, ticket, options); + + return Task.CompletedTask; + } + + public Task RetrieveAsync(string key) + { + return Task.FromResult(cache.Get(key)); + } + + public Task RemoveAsync(string key) + { + cache.Remove(key); + + return Task.CompletedTask; + } +} diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 5d5df6647..da5294f5c 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -248,7 +248,8 @@ public class StatsService : IStatsService DotnetVersion = Environment.Version.ToString(), OpdsEnabled = serverSettings.EnableOpds, EncodeMediaAs = serverSettings.EncodeMediaAs, - MatchedMetadataEnabled = mediaSettings.Enabled + MatchedMetadataEnabled = mediaSettings.Enabled, + OidcEnabled = !string.IsNullOrEmpty(serverSettings.OidcConfig.Authority), }; dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; @@ -308,6 +309,7 @@ public class StatsService : IStatsService libDto.UsingFolderWatching = library.FolderWatching; libDto.CreateCollectionsFromMetadata = library.ManageCollections; libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; + libDto.EnabledMetadata = library.EnableMetadata; libDto.LibraryType = library.Type; dto.Libraries.Add(libDto); @@ -353,7 +355,9 @@ public class StatsService : IStatsService userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList(); userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count; userDto.SmartFilterCreatedCount = user.SmartFilters.Count; + userDto.IsSharingReviews = user.UserPreferences.ShareReviews; userDto.WantToReadSeriesCount = user.WantToRead.Count; + userDto.IdentityProvider = user.IdentityProvider; if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries)) { diff --git a/API/Startup.cs b/API/Startup.cs index 2df56c504..eeb9144bb 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -136,7 +136,7 @@ public class Startup } }); services.AddCors(); - services.AddIdentityServices(_config); + services.AddIdentityServices(_config, _env); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index ba4fd09b7..d18352aac 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text.Json; using Kavita.Common.EnvironmentInfo; @@ -14,6 +15,8 @@ public static class Configuration public const int DefaultHttpPort = 5000; public const int DefaultTimeOutSecs = 90; public const long DefaultCacheMemory = 75; + public const string DefaultOidcAuthority = ""; + public const string DefaultOidcClientId = "kavita"; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development @@ -50,6 +53,13 @@ public static class Configuration set => SetCacheSize(GetAppSettingFilename(), value); } + /// You must set this object to update the settings, setting one if it's fields will not save to disk + public static OpenIdConnectSettings OidcSettings + { + get => GetOpenIdConnectSettings(GetAppSettingFilename()); + set => SetOpenIdConnectSettings(GetAppSettingFilename(), value); + } + public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename()); private static string GetAppSettingFilename() @@ -312,6 +322,43 @@ public static class Configuration } #endregion + #region OIDC + + private static OpenIdConnectSettings GetOpenIdConnectSettings(string filePath) + { + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + + return jsonObj.OpenIdConnectSettings ?? new OpenIdConnectSettings(); + } + catch (Exception ex) + { + Console.WriteLine("Error reading app settings: " + ex.Message); + } + + return new OpenIdConnectSettings(); + } + + private static void SetOpenIdConnectSettings(string filePath, OpenIdConnectSettings value) + { + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.OpenIdConnectSettings = value; + json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(filePath, json); + } + catch (Exception) + { + /* Swallow exception */ + } + } + + #endregion + private sealed class AppSettings { public string TokenKey { get; set; } @@ -326,6 +373,20 @@ public static class Configuration public long Cache { get; set; } = DefaultCacheMemory; // ReSharper disable once MemberHidesStaticFromOuterClass public bool AllowIFraming { get; init; } = false; + public OpenIdConnectSettings OpenIdConnectSettings { get; set; } = new(); #pragma warning restore S3218 } + + public class OpenIdConnectSettings + { + public string Authority { get; set; } = DefaultOidcAuthority; + public string ClientId { get; set; } = DefaultOidcClientId; + public string Secret { get; set; } = string.Empty; + public List CustomScopes { get; set; } = []; + + public bool Enabled => + !string.IsNullOrEmpty(Authority) && + !string.IsNullOrEmpty(ClientId) && + !string.IsNullOrEmpty(Secret); + } } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index b43419d7d..893ec6248 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/UI/Web/angular.json b/UI/Web/angular.json index dd9000525..bf6818f68 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -99,7 +99,8 @@ "sslKey": "./ssl/server.key", "sslCert": "./ssl/server.crt", "ssl": false, - "buildTarget": "kavita-webui:build" + "buildTarget": "kavita-webui:build", + "proxyConfig": "proxy.conf.json" }, "configurations": { "production": { diff --git a/UI/Web/proxy.conf.json b/UI/Web/proxy.conf.json new file mode 100644 index 000000000..b2ca6d221 --- /dev/null +++ b/UI/Web/proxy.conf.json @@ -0,0 +1,37 @@ +{ + "/api": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + }, + "/hubs": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true, + "logLevel": "debug", + "ws": true + }, + "/oidc/login": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + }, + "/oidc/logout": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + }, + "/signin-oidc": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true + }, + "/signout-callback-oidc": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true + } +} diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index 41a8b1eef..5422a7fb8 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -1,16 +1,21 @@ -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import { CanActivate, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; import {TranslocoService} from "@jsverse/transloco"; +import {APP_BASE_HREF} from "@angular/common"; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { - public urlKey: string = 'kavita--auth-intersection-url'; + + public static urlKey: string = 'kavita--auth-intersection-url'; + + baseURL = inject(APP_BASE_HREF); + constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService, @@ -23,7 +28,10 @@ export class AuthGuard implements CanActivate { return true; } - localStorage.setItem(this.urlKey, window.location.pathname); + const path = window.location.pathname; + if (path !== '/login' && !path.startsWith(this.baseURL + "registration") && path !== '') { + localStorage.setItem(AuthGuard.urlKey, path); + } this.router.navigateByUrl('/login'); return false; }) diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 503ca4516..834179396 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -1,4 +1,4 @@ -import {Injectable} from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { Router } from '@angular/router'; @@ -6,9 +6,14 @@ import { ToastrService } from 'ngx-toastr'; import { catchError } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; import {translate, TranslocoService} from "@jsverse/transloco"; +import {AuthGuard} from "../_guards/auth.guard"; +import {APP_BASE_HREF} from "@angular/common"; @Injectable() export class ErrorInterceptor implements HttpInterceptor { + + baseURL = inject(APP_BASE_HREF); + constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService, private translocoService: TranslocoService) {} @@ -26,7 +31,7 @@ export class ErrorInterceptor implements HttpInterceptor { this.handleValidationError(error); break; case 401: - this.handleAuthError(error); + this.handleAuthError(request, error); break; case 404: this.handleNotFound(error); @@ -114,19 +119,31 @@ export class ErrorInterceptor implements HttpInterceptor { console.error('500 error:', error); } - private handleAuthError(error: any) { + private handleAuthError(req: HttpRequest, error: any) { // Special hack for register url, to not care about auth if (location.href.includes('/registration/confirm-email?token=')) { return; } + + const path = window.location.pathname; + if (path !== '/login' && !path.startsWith(this.baseURL+"registration") && path !== '') { + localStorage.setItem(AuthGuard.urlKey, path); + } + + if (error.error && error.error !== 'Unauthorized') { + this.toast(translate(error.error)); + } + // NOTE: Signin has error.error or error.statusText available. // if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334 - this.accountService.logout(); + + // Ensure AutoLogin is skipped when the OIDC endpoint is called + this.accountService.logout(req.method === 'GET' && req.url.endsWith('/api/account')); } // Assume the title is already translated private toast(message: string, title?: string) { - if (message.startsWith('errors.')) { + if ((message+'').startsWith('errors.')) { this.toastr.error(this.translocoService.translate(message), title); } else { this.toastr.error(message, title); diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index 711b8ee11..d9cd47327 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -10,17 +10,15 @@ export class JwtInterceptor implements HttpInterceptor { constructor(private accountService: AccountService) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { - return this.accountService.currentUser$.pipe( - take(1), - switchMap(user => { - if (user) { - request = request.clone({ - setHeaders: { - Authorization: `Bearer ${user.token}` - } - }); + const user = this.accountService.currentUserSignal(); + if (user && user.token) { + request = request.clone({ + setHeaders: { + Authorization: `Bearer ${user.token}` } - return next.handle(request); - })); + }); + } + + return next.handle(request); } } diff --git a/UI/Web/src/app/_models/auth/member.ts b/UI/Web/src/app/_models/auth/member.ts index aaa45f332..407416c1e 100644 --- a/UI/Web/src/app/_models/auth/member.ts +++ b/UI/Web/src/app/_models/auth/member.ts @@ -1,5 +1,6 @@ import {AgeRestriction} from '../metadata/age-restriction'; import {Library} from '../library/library'; +import {IdentityProvider} from "../user"; export interface Member { id: number; @@ -13,4 +14,5 @@ export interface Member { libraries: Library[]; ageRestriction: AgeRestriction; isPending: boolean; + identityProvider: IdentityProvider; } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index c94a9485d..150e9768c 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -13,4 +13,12 @@ export interface User { ageRestriction: AgeRestriction; hasRunScrobbleEventGeneration: boolean; scrobbleEventGenerationRan: string; // datetime + identityProvider: IdentityProvider, } + +export enum IdentityProvider { + Kavita = 0, + OpenIdConnect = 1, +} + +export const IdentityProviders: IdentityProvider[] = [IdentityProvider.Kavita, IdentityProvider.OpenIdConnect]; diff --git a/UI/Web/src/app/_pipes/age-rating.pipe.ts b/UI/Web/src/app/_pipes/age-rating.pipe.ts index f99a77f72..ce5ed31fe 100644 --- a/UI/Web/src/app/_pipes/age-rating.pipe.ts +++ b/UI/Web/src/app/_pipes/age-rating.pipe.ts @@ -12,13 +12,17 @@ export class AgeRatingPipe implements PipeTransform { private readonly translocoService = inject(TranslocoService); - transform(value: AgeRating | AgeRatingDto | undefined): string { + transform(value: AgeRating | AgeRatingDto | undefined | string): string { if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown'); if (value.hasOwnProperty('title')) { return (value as AgeRatingDto).title; } + if (typeof value === 'string') { + value = parseInt(value, 10) as AgeRating; + } + switch (value) { case AgeRating.Unknown: return this.translocoService.translate('age-rating-pipe.unknown'); diff --git a/UI/Web/src/app/_pipes/identity-provider.pipe.ts b/UI/Web/src/app/_pipes/identity-provider.pipe.ts new file mode 100644 index 000000000..1315d59b6 --- /dev/null +++ b/UI/Web/src/app/_pipes/identity-provider.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {IdentityProvider} from "../_models/user"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'identityProviderPipe' +}) +export class IdentityProviderPipePipe implements PipeTransform { + + transform(value: IdentityProvider): string { + switch (value) { + case IdentityProvider.Kavita: + return translate("identity-provider-pipe.kavita"); + case IdentityProvider.OpenIdConnect: + return translate("identity-provider-pipe.oidc"); + } + } + +} diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index f1f91143f..df96eeb7d 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,4 +1,4 @@ -import {HttpClient} from '@angular/common/http'; +import {HttpClient, HttpHeaders} from '@angular/common/http'; import {DestroyRef, inject, Injectable} from '@angular/core'; import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; @@ -13,7 +13,7 @@ import {UserUpdateEvent} from '../_models/events/user-update-event'; import {AgeRating} from '../_models/metadata/age-rating'; import {AgeRestriction} from '../_models/metadata/age-restriction'; import {TextResonse} from '../_types/text-response'; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; import {LicenseService} from "./license.service"; import {LocalizationService} from "./localization.service"; @@ -63,7 +63,7 @@ export class AccountService { return this.hasAdminRole(u); }), shareReplay({bufferSize: 1, refCount: true})); - + public readonly currentUserSignal = toSignal(this.currentUserSource); /** * SetTimeout handler for keeping track of refresh token call @@ -205,14 +205,22 @@ export class AccountService { ); } + getAccount() { + return this.httpClient.get(this.baseUrl + 'account').pipe( + tap((response: User) => { + const user = response; + if (user) { + this.setCurrentUser(user); + } + }), + takeUntilDestroyed(this.destroyRef) + ); + } + setCurrentUser(user?: User, refreshConnections = true) { const isSameUser = this.currentUser === user; if (user) { - user.roles = []; - const roles = this.getDecodedToken(user.token).role; - Array.isArray(roles) ? user.roles = roles : user.roles.push(roles); - localStorage.setItem(this.userKey, JSON.stringify(user)); localStorage.setItem(AccountService.lastLoginKey, user.username); @@ -240,18 +248,30 @@ export class AccountService { this.messageHub.createHubConnection(this.currentUser); this.licenseService.hasValidLicense().subscribe(); } - this.startRefreshTokenTimer(); + if (this.currentUser.token) { + this.startRefreshTokenTimer(); + } } } - logout() { + logout(skipAutoLogin: boolean = false) { + const user = this.currentUserSignal(); + if (!user) return; + localStorage.removeItem(this.userKey); this.currentUserSource.next(undefined); this.currentUser = undefined; this.stopRefreshTokenTimer(); this.messageHub.stopHubConnection(); - // Upon logout, perform redirection - this.router.navigateByUrl('/login'); + + if (!user.token) { + window.location.href = '/oidc/logout'; + return; + } + + this.router.navigate(['/login'], { + queryParams: {skipAutoLogin: skipAutoLogin} + }); } @@ -269,6 +289,11 @@ export class AccountService { ); } + isOidcAuthenticated() { + return this.httpClient.get(this.baseUrl + 'account/oidc-authenticated', TextResonse) + .pipe(map(res => res == "true")); + } + isEmailConfirmed() { return this.httpClient.get(this.baseUrl + 'account/email-confirmed'); } @@ -410,7 +435,8 @@ export class AccountService { private refreshToken() { - if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of(); + if (this.currentUser === null || this.currentUser === undefined || !this.isOnline || !this.currentUser.token) return of(); + return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { if (this.currentUser) { diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 0aad76ef7..37de1e6e8 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -11,6 +11,8 @@ import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; import {WikiLink} from "../_models/wiki"; +import {AuthGuard} from "../_guards/auth.guard"; +import {SettingsService} from "../admin/settings.service"; /** * NavItem used to construct the dropdown or NavLinkModal on mobile @@ -173,10 +175,24 @@ export class NavService { } logout() { - this.accountService.logout(); this.hideNavBar(); this.hideSideNav(); - this.router.navigateByUrl('/login'); + this.accountService.logout(); + } + + handleLogin() { + this.showNavBar(); + this.showSideNav(); + + // Check if user came here from another url, else send to library route + const pageResume = localStorage.getItem(AuthGuard.urlKey); + if (pageResume && pageResume !== '/login') { + localStorage.setItem(AuthGuard.urlKey, ''); + this.router.navigateByUrl(pageResume); + } else { + localStorage.setItem(AuthGuard.urlKey, ''); + this.router.navigateByUrl('/home'); + } } /** diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts new file mode 100644 index 000000000..2232a2ff8 --- /dev/null +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -0,0 +1,25 @@ +import {AgeRating} from "../../_models/metadata/age-rating"; + +export interface OidcPublicConfig { + autoLogin: boolean; + disablePasswordAuthentication: boolean; + providerName: string; + enabled: boolean; +} + +export interface OidcConfig extends OidcPublicConfig { + authority: string; + clientId: string; + secret: string; + provisionAccounts: boolean; + requireVerifiedEmail: boolean; + syncUserSettings: boolean; + rolesPrefix: string; + rolesClaim: string; + customScopes: string[]; + defaultRoles: string[]; + defaultLibraries: number[]; + defaultAgeRestriction: AgeRating; + defaultIncludeUnknowns: boolean; +} + diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index 584de9fcc..218372aca 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -1,6 +1,7 @@ import {EncodeFormat} from "./encode-format"; import {CoverImageSize} from "./cover-image-size"; import {SmtpConfig} from "./smtp-config"; +import {OidcConfig} from "./oidc-config"; export interface ServerSettings { cacheDirectory: string; @@ -25,6 +26,7 @@ export interface ServerSettings { onDeckUpdateDays: number; coverImageSize: CoverImageSize; smtpConfig: SmtpConfig; + oidcConfig: OidcConfig; installId: string; installVersion: string; } diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index cbcaad08b..371c70171 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -1,17 +1,41 @@ - +