From df9d970a42b8368e3c79db600cc518aad9f3ea07 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 24 May 2025 13:57:06 +0200 Subject: [PATCH 01/27] POC oidc login --- API/API.csproj | 2 +- API/Controllers/AccountController.cs | 22 +- API/Controllers/OidcControlller.cs | 23 + API/DTOs/Settings/OidcConfigDto.cs | 32 + API/DTOs/Settings/ServerSettingDTO.cs | 5 + .../20250520073818_OpenIDConnect.Designer.cs | 3574 +++++++++++++++++ .../20250520073818_OpenIDConnect.cs | 28 + .../Migrations/DataContextModelSnapshot.cs | 3 + API/Data/Repositories/UserRepository.cs | 11 + API/Data/Seed.cs | 10 + API/Entities/AppUser.cs | 5 + API/Entities/Enums/ServerSettingKey.cs | 30 + .../ApplicationServiceExtensions.cs | 2 + API/Extensions/ClaimsPrincipalExtensions.cs | 15 + API/Extensions/IdentityServiceExtensions.cs | 159 +- API/Extensions/StringExtensions.cs | 9 + .../Converters/ServerSettingConverter.cs | 24 + API/Helpers/RolesClaimsTransformation.cs | 47 + API/Services/AccountService.cs | 56 + API/Services/OidcService.cs | 209 + API/Services/SettingsService.cs | 69 +- Kavita.Common/Configuration.cs | 86 + UI/Web/package-lock.json | 29 +- UI/Web/package.json | 1 + .../src/app/_interceptors/jwt.interceptor.ts | 2 +- UI/Web/src/app/_models/user.ts | 3 + UI/Web/src/app/_routes/oidc-routing.module.ts | 9 + UI/Web/src/app/_services/account.service.ts | 27 +- .../src/app/_services/message-hub.service.ts | 2 +- UI/Web/src/app/_services/oidc.service.ts | 127 + UI/Web/src/app/admin/_models/oidc-config.ts | 9 + .../src/app/admin/_models/server-settings.ts | 2 + .../manage-open-idconnect.component.html | 111 + .../manage-open-idconnect.component.scss | 8 + .../manage-open-idconnect.component.ts | 92 + UI/Web/src/app/app-routing.module.ts | 4 + UI/Web/src/app/app.component.ts | 2 + .../nav-header/nav-header.component.ts | 3 + .../oidc-callback.component.html | 15 + .../oidc-callback.component.scss | 3 + .../oidc-callback/oidc-callback.component.ts | 48 + .../user-login/user-login.component.html | 5 + .../user-login/user-login.component.ts | 62 +- .../settings/settings.component.html | 8 + .../settings/settings.component.ts | 70 +- .../preference-nav.component.ts | 2 + UI/Web/src/assets/langs/en.json | 38 +- UI/Web/src/main.ts | 2 + 48 files changed, 5009 insertions(+), 96 deletions(-) create mode 100644 API/Controllers/OidcControlller.cs create mode 100644 API/DTOs/Settings/OidcConfigDto.cs create mode 100644 API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs create mode 100644 API/Data/Migrations/20250520073818_OpenIDConnect.cs create mode 100644 API/Helpers/RolesClaimsTransformation.cs create mode 100644 API/Services/OidcService.cs create mode 100644 UI/Web/src/app/_routes/oidc-routing.module.ts create mode 100644 UI/Web/src/app/_services/oidc.service.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/app/registration/oidc-callback/oidc-callback.component.html create mode 100644 UI/Web/src/app/registration/oidc-callback/oidc-callback.component.scss create mode 100644 UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts diff --git a/API/API.csproj b/API/API.csproj index f9a889d74..39499ebd0 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -71,7 +71,7 @@ - + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index c504e1ce7..09cd2fd68 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -74,6 +74,18 @@ public class AccountController : BaseApiController _localizationService = localizationService; } + [HttpGet] + public async Task> GetCurrentUserAsync() + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); + + return Ok(await ConstructUserDto(user)); + } + /// /// Update a user's password /// @@ -245,6 +257,11 @@ public class AccountController : BaseApiController } } + return Ok(await ConstructUserDto(user)); + } + + private async Task ConstructUserDto(AppUser user) + { // Update LastActive on account user.UpdateLastActive(); @@ -265,12 +282,11 @@ public class AccountController : BaseApiController 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; } /// diff --git a/API/Controllers/OidcControlller.cs b/API/Controllers/OidcControlller.cs new file mode 100644 index 000000000..78570d85b --- /dev/null +++ b/API/Controllers/OidcControlller.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Settings; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers; + +[AllowAnonymous] +public class OidcController(ILogger logger, IUnitOfWork unitOfWork): BaseApiController +{ + + // TODO: Decide what we want to expose here, not really anything useful in it. But the discussion is needed + // Public endpoint + [HttpGet("config")] + public async Task> GetOidcConfig() + { + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settings.OidcConfig); + } + +} diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs new file mode 100644 index 000000000..a1c2eb299 --- /dev/null +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -0,0 +1,32 @@ +#nullable enable +namespace API.DTOs.Settings; + +public class OidcConfigDto +{ + /// + /// Base url for authority, must have /.well-known/openid-configuration + /// + public string? Authority { get; set; } + + /// + /// ClientId configured in your OpenID Connect provider + /// + public string? ClientId { get; set; } + /// + /// Create a new account when someone logs in with an unmatched account, if is true, + /// will account will be verified by default + /// + public bool ProvisionAccounts { get; set; } + /// + /// Require emails from OpenIDConnect to be verified before use + /// + public bool RequireVerifiedEmail { get; set; } + /// + /// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in. + /// + public bool ProvisionUserSettings { get; set; } + /// + /// Try logging in automatically when opening the app + /// + public bool AutoLogin { get; set; } +} 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/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs b/API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs new file mode 100644 index 000000000..936946bdd --- /dev/null +++ b/API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs @@ -0,0 +1,3574 @@ +// +using System; +using API.Data; +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("20250520073818_OpenIDConnect")] + partial class OpenIDConnect + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + 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("ExternalId") + .HasColumnType("TEXT"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + 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("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.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("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("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("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("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("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("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.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("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + 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/20250520073818_OpenIDConnect.cs b/API/Data/Migrations/20250520073818_OpenIDConnect.cs new file mode 100644 index 000000000..5729f55bd --- /dev/null +++ b/API/Data/Migrations/20250520073818_OpenIDConnect.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class OpenIDConnect : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExternalId", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExternalId", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index bdeb3d7c4..e70496a13 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -85,6 +85,9 @@ namespace API.Data.Migrations b.Property("EmailConfirmed") .HasColumnType("INTEGER"); + b.Property("ExternalId") + .HasColumnType("TEXT"); + b.Property("HasRunScrobbleEventGeneration") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index e55338c8b..9891f2757 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -107,6 +107,7 @@ public interface IUserRepository Task> GetDashboardStreamsByIds(IList streamIds); Task> GetUserTokenInfo(); Task GetUserByDeviceEmail(string deviceEmail); + Task GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None); } public class UserRepository : IUserRepository @@ -557,6 +558,16 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(); } + public async Task GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None) + { + if (string.IsNullOrEmpty(externalId)) return null; + + return await _context.AppUser + .Where(u => u.ExternalId == externalId) + .Includes(includes) + .FirstOrDefaultAsync(); + } + public async Task> GetAdminUsersAsync() { diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 74bfbb296..a5e2c6431 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -252,6 +252,12 @@ 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.OidcAuthority, Value = Configuration.OidcAuthority }, + new() { Key = ServerSettingKey.OidcClientId, Value = Configuration.OidcClientId}, + new() { Key = ServerSettingKey.OidcAutoLogin, Value = "false"}, + new() { Key = ServerSettingKey.OidcProvisionAccounts, Value = "false"}, + new() { Key = ServerSettingKey.OidcRequireVerifiedEmail, Value = "true"}, + new() { Key = ServerSettingKey.OidcProvisionUserSettings, Value = "false"}, new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, @@ -288,6 +294,10 @@ public static class Seed DirectoryService.BackupDirectory + string.Empty; (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = Configuration.CacheSize + string.Empty; + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcAuthority)).Value = + Configuration.OidcAuthority + string.Empty; + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcClientId)).Value = + Configuration.OidcClientId + string.Empty; await context.SaveChangesAsync(); } diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 50f795041..86f461c5d 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -88,6 +88,11 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// Kavita+ only public DateTime ScrobbleEventGenerationRan { get; set; } + /// + /// The sub returned the by OIDC provider + /// + public string? ExternalId { get; set; } + /// /// A list of Series the user doesn't want scrobbling for diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index b1050d553..7f0801b5d 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -197,4 +197,34 @@ public enum ServerSettingKey /// [Description("FirstInstallVersion")] FirstInstallVersion = 39, + /// + /// Optional OpenID Connect Authority URL + /// + [Description("OpenIDConnectAuthority")] + OidcAuthority = 40, + /// + /// Optional OpenID Connect ClientId, default to kavita + /// + [Description("OpenIDConnectClientId")] + OidcClientId = 41, + /// + /// Optional OpenID Connect ClientSecret, required if authority is set + /// + [Description("OpenIdConnectAutoLogin")] + OidcAutoLogin = 42, + /// + /// If true, auto creates a new account when someone logs in via OpenID Connect + /// + [Description("OpenIDConnectCreateAccounts")] + OidcProvisionAccounts = 43, + /// + /// Require emails to be verified by the OpenID Connect provider when creating accounts on login + /// + [Description("OpenIDConnectVerifiedEmail")] + OidcRequireVerifiedEmail = 44, + /// + /// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in. + /// + [Description("OpenIDConnectSyncUserSettings")] + OidcProvisionUserSettings = 45, } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index e95c4f65e..4a7debd0e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -80,6 +80,8 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 2e86f8bbd..bebc4e809 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -8,6 +8,8 @@ namespace API.Extensions; public static class ClaimsPrincipalExtensions { private const string NotAuthenticatedMessage = "User is not authenticated"; + private static readonly string EmailVerifiedClaimType = "email_verified"; + /// /// Get's the authenticated user's username /// @@ -26,4 +28,17 @@ 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; + } } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 9549e9a2c..b55be90b7 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,20 +1,34 @@ using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +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.Helpers; +using API.Services; +using Kavita.Common; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext; +using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext; namespace API.Extensions; #nullable enable public static class IdentityServiceExtensions { + private const string DynamicJwt = nameof(DynamicJwt); + private const string OpenIdConnect = nameof(OpenIdConnect); + private const string LocalIdentity = nameof(LocalIdentity); + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) { services.Configure(options => @@ -47,42 +61,139 @@ public static class IdentityServiceExtensions .AddRoleValidator>() .AddEntityFrameworkStores(); - - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + var auth = services.AddAuthentication(DynamicJwt) + .AddPolicyScheme(DynamicJwt, JwtBearerDefaults.AuthenticationScheme, options => { - options.TokenValidationParameters = new TokenValidationParameters() + var iss = Configuration.OidcAuthority; + var enabled = Configuration.OidcEnabled; + options.ForwardDefaultSelector = context => { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)), - ValidateIssuer = false, - ValidateAudience = false, - ValidIssuer = "Kavita" - }; + if (!enabled) + return LocalIdentity; - options.Events = new JwtBearerEvents() - { - OnMessageReceived = context => + var fullAuth = + context.Request.Headers["Authorization"].FirstOrDefault() ?? + context.Request.Query["access_token"].FirstOrDefault(); + + var token = fullAuth?.TrimPrefix("Bearer "); + + if (string.IsNullOrEmpty(token)) + return LocalIdentity; + + var handler = new JwtSecurityTokenHandler(); + try { - 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; + var jwt = handler.ReadJwtToken(token); + if (jwt.Issuer == iss) return OpenIdConnect; } + catch + { + /* Swallow */ + } + + return LocalIdentity; }; }); + + if (Configuration.OidcEnabled) + { + services.AddScoped(); + + // TODO: Investigate on how to make this not hardcoded at startup + auth.AddJwtBearer(OpenIdConnect, options => + { + options.Authority = Configuration.OidcAuthority; + options.Audience = Configuration.OidcClientId; + options.RequireHttpsMetadata = options.Authority.StartsWith("https://"); + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidAudience = Configuration.OidcClientId, + ValidIssuer = Configuration.OidcAuthority, + + ValidateIssuer = true, + ValidateAudience = true, + + ValidateIssuerSigningKey = true, + RequireExpirationTime = true, + ValidateLifetime = true, + RequireSignedTokens = true + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = SetTokenFromQuery, + OnTokenValidated = OidcClaimsPrincipalConverter + }; + }); + } + + 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.AddAuthorization(opt => { 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)); + opt.AddPolicy("RequireDownloadRole", + policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); + opt.AddPolicy("RequireChangePasswordRole", + policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); }); return services; } + + private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx) + { + var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); + if (ctx.Principal == null) return; + + var user = await oidcService.LoginOrCreate(ctx.Principal); + if (user == null) return; + + + 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 = ctx.HttpContext.RequestServices.GetRequiredService>(); + var roles = await userManager.GetRolesAsync(user); + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + claims.AddRange(ctx.Principal.Claims); + + var identity = new ClaimsIdentity(claims, ctx.Scheme.Name); + var principal = new ClaimsPrincipal(identity); + ctx.HttpContext.User = principal; + ctx.Principal = principal; + + ctx.Success(); + } + + 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..d5467482e 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -52,4 +52,13 @@ 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 value; + + if (!value.StartsWith(prefix)) return value; + + return value.Substring(prefix.Length); + } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 7adb5228f..4e897354a 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -129,6 +129,30 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.FirstInstallVersion: destination.FirstInstallVersion = row.Value; break; + case ServerSettingKey.OidcAuthority: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.Authority = row.Value; + break; + case ServerSettingKey.OidcClientId: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.ClientId = row.Value; + break; + case ServerSettingKey.OidcAutoLogin: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.AutoLogin = bool.Parse(row.Value); + break; + case ServerSettingKey.OidcProvisionAccounts: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.ProvisionAccounts = bool.Parse(row.Value); + break; + case ServerSettingKey.OidcRequireVerifiedEmail: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.RequireVerifiedEmail = bool.Parse(row.Value); + break; + case ServerSettingKey.OidcProvisionUserSettings: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.ProvisionUserSettings = bool.Parse(row.Value); + break; case ServerSettingKey.LicenseKey: case ServerSettingKey.EnableAuthentication: case ServerSettingKey.EmailServiceUrl: diff --git a/API/Helpers/RolesClaimsTransformation.cs b/API/Helpers/RolesClaimsTransformation.cs new file mode 100644 index 000000000..bf090bf33 --- /dev/null +++ b/API/Helpers/RolesClaimsTransformation.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Kavita.Common; +using Microsoft.AspNetCore.Authentication; + +namespace API.Helpers; + +/// +/// Adds assigned roles from Keycloak under the default claim +/// +public class RolesClaimsTransformation: IClaimsTransformation +{ + private const string ResourceAccessClaim = "resource_access"; + private string _clientId; + + public Task TransformAsync(ClaimsPrincipal principal) + { + var resourceAccess = principal.FindFirst(ResourceAccessClaim); + if (resourceAccess == null) return Task.FromResult(principal); + + var resources = JsonSerializer.Deserialize>(resourceAccess.Value); + if (resources == null) return Task.FromResult(principal); + + if (string.IsNullOrEmpty(_clientId)) + { + _clientId = Configuration.OidcClientId; + } + + var kavitaResource = resources.GetValueOrDefault(_clientId); + if (kavitaResource == null) return Task.FromResult(principal); + + foreach (var role in kavitaResource.Roles) + { + ((ClaimsIdentity)principal.Identity)?.AddClaim(new Claim(ClaimTypes.Role, role)); + } + return Task.FromResult(principal); + } + + private sealed class Resource + { + [JsonPropertyName("roles")] + public IList Roles { get; set; } = []; + } +} diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 74b6709fa..161732faf 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -5,6 +5,7 @@ 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.Errors; @@ -29,6 +30,9 @@ public interface IAccountService Task HasBookmarkPermission(AppUser? user); Task HasDownloadPermission(AppUser? user); Task CanChangeAgeRestriction(AppUser? user); + + Task UpdateLibrariesForUser(AppUser user, IList librariesIds, bool hasAdminRole); + Task> UpdateRolesForUser(AppUser user, IList roles); } public class AccountService : IAccountService @@ -143,4 +147,56 @@ public class AccountService : IAccountService return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } + + public async Task UpdateLibrariesForUser(AppUser user, IList librariesIds, bool hasAdminRole) + { + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + List libraries; + if (hasAdminRole) + { + _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + user.UserName); + libraries = allLibraries; + } + else + { + // Remove user from all libraries + foreach (var lib in allLibraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Remove(user); + user.RemoveSideNavFromLibrary(lib); + } + + libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(librariesIds, LibraryIncludes.AppUser)).ToList(); + } + + foreach (var lib in libraries) + { + lib.AppUsers ??= new List(); + 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 []; + } } diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs new file mode 100644 index 000000000..a1e6cb0db --- /dev/null +++ b/API/Services/OidcService.cs @@ -0,0 +1,209 @@ +#nullable enable +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers.Builders; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IOidcService +{ + /// + /// Returns the user authenticated with OpenID Connect + /// + /// + /// + /// if any requirements aren't met + Task LoginOrCreate(ClaimsPrincipal principal); +} + +public class OidcService(ILogger logger, UserManager userManager, + IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService +{ + private const string LibraryAccessClaim = "library"; + private const string AgeRatingClaim = "AgeRating"; + + public async Task LoginOrCreate(ClaimsPrincipal principal) + { + var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + + var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(externalId)) + throw new KavitaException("oidc.errors.missing-external-id"); + + var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); + if (user != null) + { + // await ProvisionUserSettings(settings, principal, user); + return user; + } + + var email = principal.FindFirstValue(ClaimTypes.Email); + if (string.IsNullOrEmpty(email)) + throw new KavitaException("oidc.errors.missing-email"); + + if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail()) + throw new KavitaException("oidc.errors.email-not-verified"); + + + user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences) + ?? await NewUserFromOpenIdConnect(settings, principal); + if (user == null) return null; + + user.ExternalId = externalId; + + // await ProvisionUserSettings(settings, principal, user); + + var roles = await userManager.GetRolesAsync(user); + if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) + throw new KavitaException("oidc.errors.disabled-account"); + + return user; + } + + private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal) + { + if (!settings.ProvisionAccounts) return null; + + var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); + if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null; + + var name = claimsPrincipal.FindFirstValue(ClaimTypes.Name); + name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); + name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname); + name ??= emailClaim.Value; + + var other = await unitOfWork.UserRepository.GetUserByUsernameAsync(name); + if (other != null) + { + // We match by email, so this will always be unique + name = emailClaim.Value; + } + + // TODO: Move to account service, as we're sharing code with AccountController + 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).ToString()); + throw new KavitaException("oidc.errors.creating-user"); + } + + AddDefaultStreamsToUser(user, mapper); + + if (settings.RequireVerifiedEmail) + { + // Email has been verified by OpenID Connect provider + var token = await userManager.GenerateEmailConfirmationTokenAsync(user); + await userManager.ConfirmEmailAsync(user, token); + } + + await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole); + + await unitOfWork.CommitAsync(); + return user; + } + + /// + /// Updates roles, library access and age rating. Does not assign admin role, or to admin roles + /// + /// + /// + /// + /// Extra feature, little buggy for now + private async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + { + if (!settings.ProvisionUserSettings) return; + + var userRoles = await userManager.GetRolesAsync(user); + if (userRoles.Contains(PolicyConstants.AdminRole)) return; + + await SyncRoles(claimsPrincipal, user); + await SyncLibraries(claimsPrincipal, user); + SyncAgeRating(claimsPrincipal, user); + + if (unitOfWork.HasChanges()) + await unitOfWork.CommitAsync(); + } + + private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user) + { + var roles = claimsPrincipal.FindAll(ClaimTypes.Role) + .Select(r => r.Value) + .Where(r => PolicyConstants.ValidRoles.Contains(r)) + .Where(r => r != PolicyConstants.AdminRole) + .ToList(); + if (roles.Count == 0) return; + + var errors = await accountService.UpdateRolesForUser(user, roles); + if (errors.Any()) throw new KavitaException("oidc.errors.syncing-user"); + } + + private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) + { + var libraryAccess = claimsPrincipal + .FindAll(LibraryAccessClaim) + .Select(r => r.Value) + .ToList(); + if (libraryAccess.Count == 0) return; + + var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList(); + var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + + await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); + } + + private static void SyncAgeRating(ClaimsPrincipal claimsPrincipal, AppUser user) + { + + var ageRatings = claimsPrincipal + .FindAll(AgeRatingClaim) + .Select(r => r.Value) + .ToList(); + if (ageRatings.Count == 0) return; + + var highestAgeRating = AgeRating.NotApplicable; + + foreach (var ar in ageRatings) + { + if (!Enum.TryParse(ar, out AgeRating ageRating)) continue; + + if (ageRating > highestAgeRating) + { + highestAgeRating = ageRating; + } + } + + user.AgeRestriction = highestAgeRating; + } + + // DUPLICATED CODE + private static void AddDefaultStreamsToUser(AppUser user, IMapper mapper) + { + 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); + } + } +} diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index fd44b5962..51c02d659 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -10,6 +10,7 @@ using API.Entities.Enums; using API.Extensions; using API.Logging; using API.Services.Tasks.Scanner; +using Flurl.Http; using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; @@ -172,7 +173,7 @@ public class SettingsService : ISettingsService updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); UpdateEmailSettings(setting, updateSettingsDto); - + await UpdateOidcSettings(setting, updateSettingsDto); if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) @@ -346,6 +347,26 @@ public class SettingsService : ISettingsService return updateSettingsDto; } + private async Task IsValidAuthority(string authority) + { + if (string.IsNullOrEmpty(authority)) + { + return false; + } + + var url = authority + "/.well-known/openid-configuration"; + try + { + var resp = await url.GetAsync(); + return resp.StatusCode == 200; + } + catch (Exception e) + { + _logger.LogError(e, "OpenIdConfiguration failed: {Reason}", e.Message); + return false; + } + } + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) { _directoryService.ExistOrCreate(bookmarkDirectory); @@ -379,6 +400,52 @@ public class SettingsService : ISettingsService return false; } + private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.OidcAuthority && + updateSettingsDto.OidcConfig.Authority + string.Empty != setting.Value) + { + if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty)) + { + throw new KavitaException("oidc-invalid-authority"); + } + + setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty; + Configuration.OidcAuthority = setting.Value; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OidcClientId && + updateSettingsDto.OidcConfig.ClientId + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OidcConfig.ClientId + string.Empty; + Configuration.OidcClientId = setting.Value; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OidcAutoLogin && + updateSettingsDto.OidcConfig.AutoLogin + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OidcConfig.AutoLogin + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OidcProvisionAccounts && + updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OidcProvisionUserSettings && + updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + } + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) { if (setting.Key == ServerSettingKey.EmailHost && diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index f2d64cde6..892b5c5f7 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -14,6 +14,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 +52,20 @@ public static class Configuration set => SetCacheSize(GetAppSettingFilename(), value); } + public static string OidcAuthority + { + get => GetOidcAuthority(GetAppSettingFilename()); + set => SetOidcAuthority(GetAppSettingFilename(), value); + } + + public static string OidcClientId + { + get => GetOidcClientId(GetAppSettingFilename()); + set => SetOidcClientId(GetAppSettingFilename(), value); + } + + public static bool OidcEnabled => GetOidcAuthority(GetAppSettingFilename()) != ""; + public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename()); private static string GetAppSettingFilename() @@ -312,6 +328,74 @@ public static class Configuration } #endregion + #region OIDC + + private static string GetOidcAuthority(string filePath) + { + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + return jsonObj.OidcAuthority; + } + catch (Exception ex) + { + Console.WriteLine("Error reading app settings: " + ex.Message); + } + + return string.Empty; + } + + private static void SetOidcAuthority(string filePath, string authority) + { + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.OidcAuthority = authority; + json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(filePath, json); + } + catch (Exception) + { + /* Swallow exception */ + } + } + + private static string GetOidcClientId(string filePath) + { + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + return jsonObj.OidcAudience; + } + catch (Exception ex) + { + Console.WriteLine("Error reading app settings: " + ex.Message); + } + + return string.Empty; + } + + private static void SetOidcClientId(string filePath, string audience) + { + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.OidcAudience = audience; + 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 +410,8 @@ public static class Configuration public long Cache { get; set; } = DefaultCacheMemory; // ReSharper disable once MemberHidesStaticFromOuterClass public bool AllowIFraming { get; init; } = false; + public string OidcAuthority { get; set; } = DefaultOidcAuthority; + public string OidcAudience { get; set; } = DefaultOidcClientId; #pragma warning restore S3218 } } diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index cfce8cded..f7f2fdeb5 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -33,6 +33,7 @@ "@siemens/ngx-datatable": "^22.4.1", "@swimlane/ngx-charts": "^22.0.0-alpha.0", "@tweenjs/tween.js": "^25.0.0", + "angular-oauth2-oidc": "^19.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", @@ -541,7 +542,6 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", - "dev": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -569,7 +569,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -584,7 +583,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", - "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4378,6 +4376,19 @@ } } }, + "node_modules/angular-oauth2-oidc": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-19.0.0.tgz", + "integrity": "sha512-EogHyF7MpCJSjSKIyVmdB8pJu7dU5Ilj9VNVSnFbLng4F77PIlaE4egwKUlUvk0i4ZvmO9rLXNQCm05R7Tyhcw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.2" + }, + "peerDependencies": { + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -4906,8 +4917,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -5354,7 +5364,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -5364,7 +5373,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8181,8 +8189,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -8403,7 +8410,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.85.0", @@ -8468,7 +8475,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9093,7 +9099,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/package.json b/UI/Web/package.json index 05d539aed..84ca98f93 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -41,6 +41,7 @@ "@siemens/ngx-datatable": "^22.4.1", "@swimlane/ngx-charts": "^22.0.0-alpha.0", "@tweenjs/tween.js": "^25.0.0", + "angular-oauth2-oidc": "^19.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index 711b8ee11..74f5243d3 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -16,7 +16,7 @@ export class JwtInterceptor implements HttpInterceptor { if (user) { request = request.clone({ setHeaders: { - Authorization: `Bearer ${user.token}` + Authorization: `Bearer ${user.oidcToken ?? user.token}` } }); } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index c94a9485d..a93272a37 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -4,6 +4,9 @@ import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { username: string; + // This is set by the oidc service, will always take precedence over the Kavita generated token + // When set, the refresh logic for the Kavita token will not run + oidcToken: string; token: string; refreshToken: string; roles: string[]; diff --git a/UI/Web/src/app/_routes/oidc-routing.module.ts b/UI/Web/src/app/_routes/oidc-routing.module.ts new file mode 100644 index 000000000..ba7606bc2 --- /dev/null +++ b/UI/Web/src/app/_routes/oidc-routing.module.ts @@ -0,0 +1,9 @@ +import {Routes} from "@angular/router"; +import {OidcCallbackComponent} from "../registration/oidc-callback/oidc-callback.component"; + +export const routes: Routes = [ + { + path: 'callback', + component: OidcCallbackComponent, + } +]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 6b8cdc243..23b528174 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'; @@ -90,6 +90,10 @@ export class AccountService { }); } + oidcEnabled() { + return this.httpClient.get(this.baseUrl + "oidc/enabled"); + } + canInvokeAction(user: User, action: Action) { const isAdmin = this.hasAdminRole(user); const canDownload = this.hasDownloadRole(user); @@ -167,6 +171,22 @@ export class AccountService { ); } + loginByToken(token: string) { + const headers = new HttpHeaders({ + "Authorization": `Bearer ${token}` + }) + return this.httpClient.get(this.baseUrl + 'account', {headers}).pipe( + tap((response: User) => { + const user = response; + if (user) { + user.oidcToken = token; + this.setCurrentUser(user); + } + }), + takeUntilDestroyed(this.destroyRef) + ); + } + setCurrentUser(user?: User, refreshConnections = true) { const isSameUser = this.currentUser === user; @@ -202,7 +222,10 @@ export class AccountService { this.messageHub.createHubConnection(this.currentUser); this.licenseService.hasValidLicense().subscribe(); } - this.startRefreshTokenTimer(); + // oidc handles refreshing itself + if (!this.currentUser.oidcToken) { + this.startRefreshTokenTimer(); + } } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 67f07f32e..51bfd5386 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -160,7 +160,7 @@ export class MessageHubService { createHubConnection(user: User) { this.hubConnection = new HubConnectionBuilder() .withUrl(this.hubUrl + 'messages', { - accessTokenFactory: () => user.token + accessTokenFactory: () => user.oidcToken ?? user.token }) .withAutomaticReconnect() //.withStatefulReconnect() // Requires signalr@8.0 diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts new file mode 100644 index 000000000..1d472785f --- /dev/null +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -0,0 +1,127 @@ +import {DestroyRef, Injectable} from '@angular/core'; +import {OAuthService} from "angular-oauth2-oidc"; +import {BehaviorSubject, from} from "rxjs"; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {OidcConfig} from "../admin/_models/oidc-config"; +import {AccountService} from "./account.service"; +import {NavService} from "./nav.service"; +import {Router} from "@angular/router"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {take} from "rxjs/operators"; + +@Injectable({ + providedIn: 'root' +}) +export class OidcService { + + /* + TODO: Further cleanup, nicer handling for the user + See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards + Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts + */ + + baseUrl = environment.apiUrl; + settingsSource = new BehaviorSubject(null); + settings$ = this.settingsSource.asObservable(); + + constructor( + private oauth2: OAuthService, + private httpClient: HttpClient, + private accountService: AccountService, + private navService: NavService, + private router: Router, + private destroyRef: DestroyRef, + ) { + + this.config().subscribe(oidcSetting => { + if (!oidcSetting.authority) { + return + } + + this.oauth2.configure({ + issuer: oidcSetting.authority, + clientId: oidcSetting.clientId, + requireHttps: oidcSetting.authority.startsWith("https://"), + redirectUri: window.location.origin + "/oidc/callback", + postLogoutRedirectUri: window.location.origin + "/login", + showDebugInformation: true, + responseType: 'code', + scope: "openid profile email roles offline_access", + }); + this.settingsSource.next(oidcSetting); + this.oauth2.setupAutomaticSilentRefresh(); + + this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { + if (event.type !== "token_refreshed") return; + + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (!user) return; // Don't update tokens when we're not logged in. But what's going on? + + // TODO: Do we need to refresh the SignalR connection here? + user.oidcToken = this.token; + }); + }); + + from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ + next: success => { + if (!success) return; + + this.tryLogin(); + }, + error: error => { + console.log(error); + } + }); + }) + } + + private tryLogin() { + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) return; + + if (this.token) { + this.accountService.loginByToken(this.token).subscribe({ + next: _ => { + this.doLogin(); + } + }); + } + }); + } + + + oidcLogin() { + this.oauth2.initLoginFlow(); + } + + config() { + return this.httpClient.get(this.baseUrl + "oidc/config"); + } + + get token() { + return this.oauth2.getAccessToken(); + } + + logout() { + this.oauth2.logOut(); + } + + private doLogin() { + this.navService.showNavBar(); + this.navService.showSideNav(); + + // Check if user came here from another url, else send to library route + const pageResume = localStorage.getItem('kavita--auth-intersection-url'); + if (pageResume && pageResume !== '/login') { + localStorage.setItem('kavita--auth-intersection-url', ''); + this.router.navigateByUrl(pageResume); + } else { + localStorage.setItem('kavita--auth-intersection-url', ''); + 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..ff9a0be16 --- /dev/null +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -0,0 +1,9 @@ + +export interface OidcConfig { + authority: string; + clientId: string; + provisionAccounts: boolean; + requireVerifiedEmail: boolean; + provisionUserSettings: boolean; + autoLogin: 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/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html new file mode 100644 index 000000000..92945cef6 --- /dev/null +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -0,0 +1,111 @@ + + +
+ +
+ +
+ + +

{{t('provider')}}

+ +
+ @if (settingsForm.get('authority'); as formControl) { + + + {{formControl.value}} + + + + + + } +
+ +
+ @if (settingsForm.get('clientId'); as formControl) { + + + {{formControl.value}} + + + + + @if (settingsForm.dirty || !settingsForm.untouched) { +
+ @if (formControl.errors?.requiredIf) { +
{{t('field-required', {name: 'clientId', other: formControl.errors?.requiredIf.other})}}
+ } +
+ } + +
+
+ } +
+ +
+ +
+

{{t('behaviour')}}

+ + +
+ @if(settingsForm.get('provisionAccounts'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('requireVerifiedEmail'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('provisionUserSettings'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('autoLogin'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ +
+ +
diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.scss b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.scss new file mode 100644 index 000000000..eb1d80f64 --- /dev/null +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.scss @@ -0,0 +1,8 @@ +.invalid-feedback { + display: inherit; +} + +.custom-position { + right: 5px; + top: -42px; +} diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts new file mode 100644 index 000000000..6f1bf569f --- /dev/null +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -0,0 +1,92 @@ +import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import {ServerSettings} from "../_models/server-settings"; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn +} from "@angular/forms"; +import {SettingsService} from "../settings.service"; +import {OidcConfig} from "../_models/oidc-config"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; + +@Component({ + selector: 'app-manage-open-idconnect', + imports: [ + TranslocoDirective, + ReactiveFormsModule, + SettingItemComponent, + SettingSwitchComponent + ], + templateUrl: './manage-open-idconnect.component.html', + styleUrl: './manage-open-idconnect.component.scss' +}) +export class ManageOpenIDConnectComponent implements OnInit { + + serverSettings!: ServerSettings; + oidcSettings!: OidcConfig; + settingsForm: FormGroup = new FormGroup({}); + + constructor( + private settingsService: SettingsService, + private cdRef: ChangeDetectorRef, + ) { + } + + ngOnInit(): void { + this.settingsService.getServerSettings().subscribe({ + next: data => { + this.serverSettings = data; + this.oidcSettings = this.serverSettings.oidcConfig; + + + // TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable + this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, [])); + this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')])); + this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, [])); + this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); + this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, [])); + this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, [])); + this.cdRef.markForCheck(); + } + }) + } + + save() { + const data = this.settingsForm.getRawValue(); + const newSettings = Object.assign({}, this.serverSettings); + newSettings.oidcConfig = data as OidcConfig; + + this.settingsService.updateServerSettings(newSettings).subscribe({ + next: data => { + this.serverSettings = data; + this.oidcSettings = data.oidcConfig; + this.cdRef.markForCheck(); + }, + error: error => { + console.error(error); + } + }) + } + + requiredIf(other: string): ValidatorFn { + return (control): ValidationErrors | null => { + const otherControl = this.settingsForm.get(other); + if (!otherControl) return null; + + if (otherControl.invalid) return null; + + const v = otherControl.value; + if (!v || v.length === 0) return null; + + const own = control.value; + if (own && own.length > 0) return null; + + return {'requiredIf': {'other': other, 'otherValue': v}} + } + } + +} diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 32eae3b87..c7070e1fc 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -104,6 +104,10 @@ const routes: Routes = [ path: 'login', loadChildren: () => import('./_routes/registration.router.module').then(m => m.routes) // TODO: Refactor so we just use /registration/login going forward }, + { + path: 'oidc', + loadChildren: () => import('./_routes/oidc-routing.module').then(m => m.routes) + }, {path: 'libraries', pathMatch: 'full', redirectTo: 'home'}, {path: '**', pathMatch: 'prefix', redirectTo: 'home'}, {path: '**', pathMatch: 'full', redirectTo: 'home'}, diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index b6da07617..c68d3a2d2 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -25,6 +25,7 @@ import {TranslocoService} from "@jsverse/transloco"; import {VersionService} from "./_services/version.service"; import {LicenseService} from "./_services/license.service"; import {LocalizationService} from "./_services/localization.service"; +import {OidcService} from "./_services/oidc.service"; @Component({ selector: 'app-root', @@ -51,6 +52,7 @@ export class AppComponent implements OnInit { private readonly document = inject(DOCUMENT); private readonly translocoService = inject(TranslocoService); private readonly versionService = inject(VersionService); // Needs to be injected to run background job + private readonly oidcService = inject(OidcService); // Needed to auto login private readonly licenseService = inject(LicenseService); private readonly localizationService = inject(LocalizationService); diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index 980a1be55..66e7bb238 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; import {WikiLink} from "../../../_models/wiki"; import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component"; +import {OidcService} from "../../../_services/oidc.service"; @Component({ selector: 'app-nav-header', @@ -64,6 +65,7 @@ export class NavHeaderComponent implements OnInit { private readonly searchService = inject(SearchService); private readonly filterUtilityService = inject(FilterUtilitiesService); protected readonly accountService = inject(AccountService); + private readonly oidcService = inject(OidcService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); protected readonly navService = inject(NavService); @@ -135,6 +137,7 @@ export class NavHeaderComponent implements OnInit { this.accountService.logout(); this.navService.hideNavBar(); this.navService.hideSideNav(); + this.oidcService.logout(); this.router.navigateByUrl('/login'); } diff --git a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html new file mode 100644 index 000000000..b5276f075 --- /dev/null +++ b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html @@ -0,0 +1,15 @@ + + +

{{t('title')}}

+ + + @if (error.length > 0) { +
+ {{t(error)}} +
+ } + + +
+
+
diff --git a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.scss b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.scss new file mode 100644 index 000000000..d36bd4999 --- /dev/null +++ b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.scss @@ -0,0 +1,3 @@ +.invalid-feedback { + display: inherit; +} diff --git a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts new file mode 100644 index 000000000..67e78c6ec --- /dev/null +++ b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts @@ -0,0 +1,48 @@ +import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; +import {SplashContainerComponent} from "../_components/splash-container/splash-container.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {AccountService} from "../../_services/account.service"; +import {Router} from "@angular/router"; +import {NavService} from "../../_services/nav.service"; +import {take} from "rxjs/operators"; +import {OidcService} from "../../_services/oidc.service"; + +@Component({ + selector: 'app-oidc-callback', + imports: [ + SplashContainerComponent, + TranslocoDirective + ], + templateUrl: './oidc-callback.component.html', + styleUrl: './oidc-callback.component.scss' +}) +export class OidcCallbackComponent implements OnInit{ + + error: string = ''; + + constructor( + private accountService: AccountService, + private router: Router, + private navService: NavService, + private readonly cdRef: ChangeDetectorRef, + private oidcService: OidcService, + ) { + this.navService.hideNavBar(); + this.navService.hideSideNav(); + } + + ngOnInit(): void { + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.navService.showNavBar(); + this.navService.showSideNav(); + this.router.navigateByUrl('/home'); + this.cdRef.markForCheck(); + } + }); + } + + goToLogin() { + this.router.navigateByUrl('/login'); + } +} diff --git a/UI/Web/src/app/registration/user-login/user-login.component.html b/UI/Web/src/app/registration/user-login/user-login.component.html index da1c9aadf..148bbb426 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.html +++ b/UI/Web/src/app/registration/user-login/user-login.component.html @@ -26,6 +26,11 @@ + + @if (oidcEnabled) { + + } + diff --git a/UI/Web/src/app/registration/user-login/user-login.component.ts b/UI/Web/src/app/registration/user-login/user-login.component.ts index 5645e18ed..6508da0fe 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.ts +++ b/UI/Web/src/app/registration/user-login/user-login.component.ts @@ -1,7 +1,7 @@ import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import {ActivatedRoute, Router, RouterLink} from '@angular/router'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { AccountService } from '../../_services/account.service'; @@ -10,6 +10,9 @@ import { NavService } from '../../_services/nav.service'; import { NgIf } from '@angular/common'; import { SplashContainerComponent } from '../_components/splash-container/splash-container.component'; import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco"; +import {environment} from "../../../environments/environment"; +import {OidcService} from "../../_services/oidc.service"; +import {forkJoin} from "rxjs"; @Component({ @@ -17,10 +20,12 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco"; templateUrl: './user-login.component.html', styleUrls: ['./user-login.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective] + imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip] }) export class UserLoginComponent implements OnInit { + baseUrl = environment.apiUrl; + loginForm: FormGroup = new FormGroup({ username: new FormControl('', [Validators.required]), password: new FormControl('', [Validators.required, Validators.maxLength(256), Validators.minLength(6), Validators.pattern("^.{6,256}$")]) @@ -35,10 +40,18 @@ export class UserLoginComponent implements OnInit { */ isLoaded: boolean = false; isSubmitting = false; + oidcEnabled = false; - constructor(private accountService: AccountService, private router: Router, private memberService: MemberService, - private toastr: ToastrService, private navService: NavService, - private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) { + constructor( + private accountService: AccountService, + private router: Router, + private memberService: MemberService, + private toastr: ToastrService, + private navService: NavService, + private readonly cdRef: ChangeDetectorRef, + private route: ActivatedRoute, + protected oidcService: OidcService, + ) { this.navService.hideNavBar(); this.navService.hideSideNav(); } @@ -71,6 +84,18 @@ export class UserLoginComponent implements OnInit { if (val != null && val.length > 0) { this.login(val); } + + const skipAutoLogin = params.get('skipAutoLogin') === 'true'; + this.oidcService.settings$.subscribe(cfg => { + if (!cfg) return; + + this.oidcEnabled = cfg.authority !== ""; + this.cdRef.markForCheck(); + + if (cfg.autoLogin && !skipAutoLogin) { + this.oidcService.oidcLogin() + } + }); }); } @@ -83,18 +108,8 @@ export class UserLoginComponent implements OnInit { this.cdRef.markForCheck(); this.accountService.login(model).subscribe(() => { this.loginForm.reset(); - this.navService.showNavBar(); - this.navService.showSideNav(); + this.doLogin() - // Check if user came here from another url, else send to library route - const pageResume = localStorage.getItem('kavita--auth-intersection-url'); - if (pageResume && pageResume !== '/login') { - localStorage.setItem('kavita--auth-intersection-url', ''); - this.router.navigateByUrl(pageResume); - } else { - localStorage.setItem('kavita--auth-intersection-url', ''); - this.router.navigateByUrl('/home'); - } this.isSubmitting = false; this.cdRef.markForCheck(); }, err => { @@ -103,4 +118,19 @@ export class UserLoginComponent implements OnInit { this.cdRef.markForCheck(); }); } + + private doLogin() { + this.navService.showNavBar(); + this.navService.showSideNav(); + + // Check if user came here from another url, else send to library route + const pageResume = localStorage.getItem('kavita--auth-intersection-url'); + if (pageResume && pageResume !== '/login') { + localStorage.setItem('kavita--auth-intersection-url', ''); + this.router.navigateByUrl(pageResume); + } else { + localStorage.setItem('kavita--auth-intersection-url', ''); + this.router.navigateByUrl('/home'); + } + } } diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 168c98a85..82e65e21a 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -17,6 +17,14 @@ } } + @defer (when fragment === SettingsTabId.OpenIDConnect; prefetch on idle) { + @if (fragment === SettingsTabId.OpenIDConnect) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.Email; prefetch on idle) { @if (fragment === SettingsTabId.Email) {
diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 84b1bfe0a..194587bb7 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -52,43 +52,45 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb import { ManageMetadataSettingsComponent } from "../../../admin/manage-metadata-settings/manage-metadata-settings.component"; +import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component"; @Component({ selector: 'app-settings', - imports: [ - ChangeAgeRestrictionComponent, - ChangeEmailComponent, - ChangePasswordComponent, - ManageDevicesComponent, - ManageOpdsComponent, - ManageScrobblingProvidersComponent, - ManageUserPreferencesComponent, - SideNavCompanionBarComponent, - ThemeManagerComponent, - TranslocoDirective, - UserStatsComponent, - AsyncPipe, - LicenseComponent, - ManageEmailSettingsComponent, - ManageLibraryComponent, - ManageMediaSettingsComponent, - ManageSettingsComponent, - ManageSystemComponent, - ManageTasksSettingsComponent, - ManageUsersComponent, - ServerStatsComponent, - SettingFragmentPipe, - ManageScrobblingComponent, - ManageMediaIssuesComponent, - ManageCustomizationComponent, - ImportMalCollectionComponent, - ImportCblComponent, - ManageMatchedMetadataComponent, - ManageUserTokensComponent, - EmailHistoryComponent, - ScrobblingHoldsComponent, - ManageMetadataSettingsComponent - ], + imports: [ + ChangeAgeRestrictionComponent, + ChangeEmailComponent, + ChangePasswordComponent, + ManageDevicesComponent, + ManageOpdsComponent, + ManageScrobblingProvidersComponent, + ManageUserPreferencesComponent, + SideNavCompanionBarComponent, + ThemeManagerComponent, + TranslocoDirective, + UserStatsComponent, + AsyncPipe, + LicenseComponent, + ManageEmailSettingsComponent, + ManageLibraryComponent, + ManageMediaSettingsComponent, + ManageSettingsComponent, + ManageSystemComponent, + ManageTasksSettingsComponent, + ManageUsersComponent, + ServerStatsComponent, + SettingFragmentPipe, + ManageScrobblingComponent, + ManageMediaIssuesComponent, + ManageCustomizationComponent, + ImportMalCollectionComponent, + ImportCblComponent, + ManageMatchedMetadataComponent, + ManageUserTokensComponent, + EmailHistoryComponent, + ScrobblingHoldsComponent, + ManageMetadataSettingsComponent, + ManageOpenIDConnectComponent + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 6574fe945..941378fa4 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -21,6 +21,7 @@ export enum SettingsTabId { // Admin General = 'admin-general', + OpenIDConnect = 'admin-oidc', Email = 'admin-email', Media = 'admin-media', Users = 'admin-users', @@ -122,6 +123,7 @@ export class PreferenceNavComponent implements AfterViewInit { title: 'server-section-title', children: [ new SideNavItem(SettingsTabId.General, [Role.Admin]), + new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]), new SideNavItem(SettingsTabId.Media, [Role.Admin]), new SideNavItem(SettingsTabId.Email, [Role.Admin]), new SideNavItem(SettingsTabId.Users, [Role.Admin]), diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 19d4443b6..2870d88d2 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -5,7 +5,42 @@ "password": "{{common.password}}", "password-validation": "{{validation.password-validation}}", "forgot-password": "Forgot Password?", - "submit": "Sign in" + "submit": "Sign in", + "oidc": "Log in with OpenID Connect", + "oidc-tooltip": "This will connect you to an external site" + }, + + "oidc": { + "title": "OpenID Connect Callback", + "login": "Back to login screen", + "errors": { + "missing-external-id": "OpenID Connect provider did not return a valid identifier", + "missing-email": "OpenID Connect provider did not return a valid email", + "email-not-verified": "Your email must be verified to allow logging in via OpenID Connect", + "no-account": "No matching account found", + "disabled-account": "This account is disabled, please contact an administrator" + }, + "settings": { + "save": "{{common.save}}", + "notice": "Notice", + "restart-required": "Changing OpenID Connect settings requires a restart", + "provider": "Provider", + "behaviour": "Behaviour", + "field-required": "{{name}} is required when {{other}} is set", + + "authority": "Authority", + "authority-tooltip": "The URL to your OpenID Connect provider", + "clientId": "Client ID", + "clientId-tooltip": "The ClientID set in your OIDC provider, can be anything", + "provisionAccounts": "Provision accounts", + "provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account", + "requireVerifiedEmail": "Require verified emails", + "requireVerifiedEmail-tooltip": "Requires emails to be verified when creation an account or matching with existing ones. A newly created account with a verified email, will be auto verified on Kavita's side", + "provisionUserSettings": "Provision user settings", + "provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information", + "autoLogin": "Auto login", + "autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen" + } }, "dashboard": { @@ -1698,6 +1733,7 @@ "import-section-title": "Import", "kavitaplus-section-title": "Kavita+", "admin-general": "General", + "admin-oidc": "OpenID Connect", "admin-users": "Users", "admin-libraries": "Libraries", "admin-media": "Media", diff --git a/UI/Web/src/main.ts b/UI/Web/src/main.ts index 3e5f86b57..2a0386fa5 100644 --- a/UI/Web/src/main.ts +++ b/UI/Web/src/main.ts @@ -20,6 +20,7 @@ import {distinctUntilChanged} from "rxjs/operators"; import {APP_BASE_HREF, PlatformLocation} from "@angular/common"; import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations'; import {HttpLoader} from "./httpLoader"; +import {provideOAuthClient} from "angular-oauth2-oidc"; const disableAnimations = !('animate' in document.documentElement); @@ -146,6 +147,7 @@ bootstrapApplication(AppComponent, { useFactory: getBaseHref, deps: [PlatformLocation] }, + provideOAuthClient(), provideHttpClient(withInterceptorsFromDi()) ] } as ApplicationConfig) From 0b64ea1622fa2422f0d60782bf73bb0ddaf273e4 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:16:17 +0200 Subject: [PATCH 02/27] Cleanup, nicer flow --- API/Controllers/OidcControlller.cs | 9 +- API/DTOs/Settings/OidcPublicConfigDto.cs | 12 ++ API/Extensions/IdentityServiceExtensions.cs | 2 +- API/Helpers/AutoMapperProfiles.cs | 3 +- API/Services/OidcService.cs | 16 +-- UI/Web/src/app/_services/nav.service.ts | 18 +++ UI/Web/src/app/_services/oidc.service.ts | 95 ++++++--------- UI/Web/src/app/admin/_models/oidc-config.ts | 6 + UI/Web/src/app/app.component.ts | 17 ++- .../nav-header/nav-header.component.ts | 10 -- .../oidc-callback.component.html | 23 ++-- .../oidc-callback/oidc-callback.component.ts | 11 +- .../user-login/user-login.component.html | 10 +- .../user-login/user-login.component.ts | 115 +++++++++--------- UI/Web/src/assets/langs/en.json | 16 +-- 15 files changed, 184 insertions(+), 179 deletions(-) create mode 100644 API/DTOs/Settings/OidcPublicConfigDto.cs diff --git a/API/Controllers/OidcControlller.cs b/API/Controllers/OidcControlller.cs index 78570d85b..2ccc570e3 100644 --- a/API/Controllers/OidcControlller.cs +++ b/API/Controllers/OidcControlller.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Settings; +using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -8,16 +9,14 @@ using Microsoft.Extensions.Logging; namespace API.Controllers; [AllowAnonymous] -public class OidcController(ILogger logger, IUnitOfWork unitOfWork): BaseApiController +public class OidcController(ILogger logger, IUnitOfWork unitOfWork, IMapper mapper): BaseApiController { - // TODO: Decide what we want to expose here, not really anything useful in it. But the discussion is needed - // Public endpoint [HttpGet("config")] - public async Task> GetOidcConfig() + public async Task> GetOidcConfig() { var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - return Ok(settings.OidcConfig); + return Ok(mapper.Map(settings.OidcConfig)); } } diff --git a/API/DTOs/Settings/OidcPublicConfigDto.cs b/API/DTOs/Settings/OidcPublicConfigDto.cs new file mode 100644 index 000000000..73bb8ee77 --- /dev/null +++ b/API/DTOs/Settings/OidcPublicConfigDto.cs @@ -0,0 +1,12 @@ +#nullable enable +namespace API.DTOs.Settings; + +public sealed record OidcPublicConfigDto +{ + /// + public string? Authority { get; set; } + /// + public string? ClientId { get; set; } + /// + public bool AutoLogin { get; set; } +} diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index b55be90b7..6bf253d1f 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -123,7 +123,7 @@ public static class IdentityServiceExtensions options.Events = new JwtBearerEvents { OnMessageReceived = SetTokenFromQuery, - OnTokenValidated = OidcClaimsPrincipalConverter + OnTokenValidated = OidcClaimsPrincipalConverter, }; }); } 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/Services/OidcService.cs b/API/Services/OidcService.cs index a1e6cb0db..9ddd239e3 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -41,21 +41,21 @@ public class OidcService(ILogger logger, UserManager userM var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(externalId)) - throw new KavitaException("oidc.errors.missing-external-id"); + throw new KavitaException("errors.oidc.missing-external-id"); var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); if (user != null) { - // await ProvisionUserSettings(settings, principal, user); + //await SyncUserSettings(settings, principal, user); return user; } var email = principal.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrEmpty(email)) - throw new KavitaException("oidc.errors.missing-email"); + throw new KavitaException("errors.oidc.missing-email"); if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail()) - throw new KavitaException("oidc.errors.email-not-verified"); + throw new KavitaException("errors.oidc.email-not-verified"); user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences) @@ -64,11 +64,11 @@ public class OidcService(ILogger logger, UserManager userM user.ExternalId = externalId; - // await ProvisionUserSettings(settings, principal, user); + //await SyncUserSettings(settings, principal, user); var roles = await userManager.GetRolesAsync(user); if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) - throw new KavitaException("oidc.errors.disabled-account"); + throw new KavitaException("errors.oidc.disabled-account"); return user; } @@ -101,7 +101,7 @@ public class OidcService(ILogger logger, UserManager userM { logger.LogError("Failed to create new user from OIDC: {Errors}", res.Errors.Select(x => x.Description).ToString()); - throw new KavitaException("oidc.errors.creating-user"); + throw new KavitaException("errors.oidc.creating-user"); } AddDefaultStreamsToUser(user, mapper); @@ -151,7 +151,7 @@ public class OidcService(ILogger logger, UserManager userM if (roles.Count == 0) return; var errors = await accountService.UpdateRolesForUser(user, roles); - if (errors.Any()) throw new KavitaException("oidc.errors.syncing-user"); + if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); } private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 0aad76ef7..3ac3898b2 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -11,6 +11,7 @@ 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 {OidcService} from "./oidc.service"; /** * NavItem used to construct the dropdown or NavLinkModal on mobile @@ -34,6 +35,7 @@ interface NavItem { export class NavService { private readonly accountService = inject(AccountService); + private readonly oidcService = inject(OidcService); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -173,12 +175,28 @@ export class NavService { } logout() { + this.oidcService.logout(); this.accountService.logout(); this.hideNavBar(); this.hideSideNav(); this.router.navigateByUrl('/login'); } + handleLogin() { + this.showNavBar(); + this.showSideNav(); + + // Check if user came here from another url, else send to library route + const pageResume = localStorage.getItem('kavita--auth-intersection-url'); + if (pageResume && pageResume !== '/login') { + localStorage.setItem('kavita--auth-intersection-url', ''); + this.router.navigateByUrl(pageResume); + } else { + localStorage.setItem('kavita--auth-intersection-url', ''); + this.router.navigateByUrl('/home'); + } + } + /** * Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state. */ diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 1d472785f..17048cc33 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -1,12 +1,10 @@ -import {DestroyRef, Injectable} from '@angular/core'; -import {OAuthService} from "angular-oauth2-oidc"; +import {DestroyRef, effect, inject, Injectable, signal} from '@angular/core'; +import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc"; import {BehaviorSubject, from} from "rxjs"; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; -import {OidcConfig} from "../admin/_models/oidc-config"; +import {OidcPublicConfig} from "../admin/_models/oidc-config"; import {AccountService} from "./account.service"; -import {NavService} from "./nav.service"; -import {Router} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {take} from "rxjs/operators"; @@ -15,24 +13,29 @@ import {take} from "rxjs/operators"; }) export class OidcService { - /* - TODO: Further cleanup, nicer handling for the user - See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards - Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts - */ + private readonly oauth2 = inject(OAuthService); + private readonly httpClient = inject(HttpClient); + private readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); baseUrl = environment.apiUrl; - settingsSource = new BehaviorSubject(null); - settings$ = this.settingsSource.asObservable(); - constructor( - private oauth2: OAuthService, - private httpClient: HttpClient, - private accountService: AccountService, - private navService: NavService, - private router: Router, - private destroyRef: DestroyRef, - ) { + private readonly _ready = signal(false); + public readonly ready = this._ready.asReadonly(); + private readonly _settings = signal(undefined); + public readonly settings = this._settings.asReadonly(); + + constructor() { + // log events in dev + if (!environment.production) { + this.oauth2.events.subscribe(event => { + if (event instanceof OAuthErrorEvent) { + console.error('OAuthErrorEvent Object:', event); + } else { + console.debug('OAuthEvent Object:', event); + } + }); + } this.config().subscribe(oidcSetting => { if (!oidcSetting.authority) { @@ -42,14 +45,15 @@ export class OidcService { this.oauth2.configure({ issuer: oidcSetting.authority, clientId: oidcSetting.clientId, - requireHttps: oidcSetting.authority.startsWith("https://"), + // Require https in production unless localhost + requireHttps: environment.production ? 'remoteOnly' : false, redirectUri: window.location.origin + "/oidc/callback", postLogoutRedirectUri: window.location.origin + "/login", - showDebugInformation: true, + showDebugInformation: !environment.production, responseType: 'code', scope: "openid profile email roles offline_access", }); - this.settingsSource.next(oidcSetting); + this._settings.set(oidcSetting); this.oauth2.setupAutomaticSilentRefresh(); this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { @@ -67,7 +71,7 @@ export class OidcService { next: success => { if (!success) return; - this.tryLogin(); + this._ready.set(true); }, error: error => { console.log(error); @@ -76,52 +80,21 @@ export class OidcService { }) } - private tryLogin() { - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) return; - if (this.token) { - this.accountService.loginByToken(this.token).subscribe({ - next: _ => { - this.doLogin(); - } - }); - } - }); - } - - - oidcLogin() { + login() { this.oauth2.initLoginFlow(); } - config() { - return this.httpClient.get(this.baseUrl + "oidc/config"); - } - - get token() { - return this.oauth2.getAccessToken(); - } - logout() { this.oauth2.logOut(); } - private doLogin() { - this.navService.showNavBar(); - this.navService.showSideNav(); - - // Check if user came here from another url, else send to library route - const pageResume = localStorage.getItem('kavita--auth-intersection-url'); - if (pageResume && pageResume !== '/login') { - localStorage.setItem('kavita--auth-intersection-url', ''); - this.router.navigateByUrl(pageResume); - } else { - localStorage.setItem('kavita--auth-intersection-url', ''); - this.router.navigateByUrl('/home'); - } + config() { + return this.httpClient.get(this.baseUrl + "oidc/config"); } - + get token() { + return this.oauth2.getAccessToken(); + } } diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index ff9a0be16..ab94dde68 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -7,3 +7,9 @@ export interface OidcConfig { provisionUserSettings: boolean; autoLogin: boolean; } + +export interface OidcPublicConfig { + authority: string; + clientId: string; + autoLogin: boolean; +} diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 90f4a3ec9..20e71b534 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, + DestroyRef, effect, HostListener, inject, OnInit @@ -100,6 +100,21 @@ export class AppComponent implements OnInit { this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup + // Login automatically when a token is available + effect(() => { + const ready = this.oidcService.ready(); + if (!ready || !this.oidcService.token) return; + + this.accountService.loginByToken(this.oidcService.token).subscribe({ + next: () => { + this.navService.handleLogin(); + }, + error: err => { + console.error(err); + } + }); + }); + } @HostListener('window:resize', ['$event']) diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index 5c9da5276..11b1f3307 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -48,7 +48,6 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv import {WikiLink} from "../../../_models/wiki"; import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component"; import {MetadataService} from "../../../_services/metadata.service"; -import {OidcService} from "../../../_services/oidc.service"; @Component({ selector: 'app-nav-header', @@ -66,7 +65,6 @@ export class NavHeaderComponent implements OnInit { private readonly searchService = inject(SearchService); private readonly filterUtilityService = inject(FilterUtilitiesService); protected readonly accountService = inject(AccountService); - private readonly oidcService = inject(OidcService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); protected readonly navService = inject(NavService); @@ -136,14 +134,6 @@ export class NavHeaderComponent implements OnInit { this.cdRef.markForCheck(); } - logout() { - this.accountService.logout(); - this.navService.hideNavBar(); - this.navService.hideSideNav(); - this.oidcService.logout(); - this.router.navigateByUrl('/login'); - } - moveFocus() { this.document.getElementById('content')?.focus(); } diff --git a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html index b5276f075..492894c7f 100644 --- a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html +++ b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.html @@ -1,15 +1,10 @@ - - -

{{t('title')}}

- - - @if (error.length > 0) { -
- {{t(error)}} -
- } - - -
-
+ + @if (showSplash()) { + +

{{t('title')}}

+ + + +
+ }
diff --git a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts index 67e78c6ec..84bae7063 100644 --- a/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts +++ b/UI/Web/src/app/registration/oidc-callback/oidc-callback.component.ts @@ -1,11 +1,10 @@ -import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; +import {ChangeDetectorRef, Component, OnInit, signal} from '@angular/core'; import {SplashContainerComponent} from "../_components/splash-container/splash-container.component"; import {TranslocoDirective} from "@jsverse/transloco"; import {AccountService} from "../../_services/account.service"; import {Router} from "@angular/router"; import {NavService} from "../../_services/nav.service"; import {take} from "rxjs/operators"; -import {OidcService} from "../../_services/oidc.service"; @Component({ selector: 'app-oidc-callback', @@ -16,16 +15,15 @@ import {OidcService} from "../../_services/oidc.service"; templateUrl: './oidc-callback.component.html', styleUrl: './oidc-callback.component.scss' }) -export class OidcCallbackComponent implements OnInit{ +export class OidcCallbackComponent implements OnInit { - error: string = ''; + showSplash = signal(false); constructor( private accountService: AccountService, private router: Router, private navService: NavService, private readonly cdRef: ChangeDetectorRef, - private oidcService: OidcService, ) { this.navService.hideNavBar(); this.navService.hideSideNav(); @@ -40,6 +38,9 @@ export class OidcCallbackComponent implements OnInit{ this.cdRef.markForCheck(); } }); + + // Show back to log in splash only after 1s, for a more seamless experience + setTimeout(() => this.showSplash.set(true), 1000); } goToLogin() { diff --git a/UI/Web/src/app/registration/user-login/user-login.component.html b/UI/Web/src/app/registration/user-login/user-login.component.html index 148bbb426..44b850a4d 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.html +++ b/UI/Web/src/app/registration/user-login/user-login.component.html @@ -2,8 +2,8 @@

{{t('title')}}

- -
+ +
@@ -22,13 +22,13 @@
- @if (oidcEnabled) { - + @if (oidcService.ready()) { + }
diff --git a/UI/Web/src/app/registration/user-login/user-login.component.ts b/UI/Web/src/app/registration/user-login/user-login.component.ts index 6508da0fe..7b760a70a 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.ts +++ b/UI/Web/src/app/registration/user-login/user-login.component.ts @@ -1,4 +1,12 @@ -import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, computed, + DestroyRef, effect, inject, + OnInit, + signal +} from '@angular/core'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; @@ -13,6 +21,7 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco"; import {environment} from "../../../environments/environment"; import {OidcService} from "../../_services/oidc.service"; import {forkJoin} from "rxjs"; +import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; @Component({ @@ -24,6 +33,15 @@ import {forkJoin} from "rxjs"; }) export class UserLoginComponent implements OnInit { + private readonly accountService = inject(AccountService); + private readonly router = inject(Router); + private readonly memberService = inject(MemberService); + private readonly toastr = inject(ToastrService); + private readonly navService = inject(NavService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly route = inject(ActivatedRoute); + protected readonly oidcService = inject(OidcService); + baseUrl = environment.apiUrl; loginForm: FormGroup = new FormGroup({ @@ -34,27 +52,31 @@ export class UserLoginComponent implements OnInit { /** * If there are no admins on the server, this will enable the registration to kick in. */ - firstTimeFlow: boolean = true; + firstTimeFlow = signal(true); /** * Used for first time the page loads to ensure no flashing */ - isLoaded: boolean = false; - isSubmitting = false; - oidcEnabled = false; + isLoaded = signal(false); + isSubmitting = signal(false); + /** + * undefined until query params are read + */ + skipAutoLogin = signal(undefined) - constructor( - private accountService: AccountService, - private router: Router, - private memberService: MemberService, - private toastr: ToastrService, - private navService: NavService, - private readonly cdRef: ChangeDetectorRef, - private route: ActivatedRoute, - protected oidcService: OidcService, - ) { - this.navService.hideNavBar(); - this.navService.hideSideNav(); - } + constructor() { + this.navService.hideNavBar(); + this.navService.hideSideNav(); + + effect(() => { + const skipAutoLogin = this.skipAutoLogin(); + const oidcConfig = this.oidcService.settings(); + if (!oidcConfig || skipAutoLogin === undefined) return; + + if (oidcConfig.autoLogin && !skipAutoLogin) { + this.oidcService.login() + } + }); + } ngOnInit(): void { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -68,34 +90,24 @@ export class UserLoginComponent implements OnInit { this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => { - this.firstTimeFlow = !adminExists; + this.firstTimeFlow.set(!adminExists); - if (this.firstTimeFlow) { + if (this.firstTimeFlow()) { this.router.navigateByUrl('registration/register'); return; } - this.isLoaded = true; - this.cdRef.markForCheck(); + this.isLoaded.set(true); }); this.route.queryParamMap.subscribe(params => { const val = params.get('apiKey'); if (val != null && val.length > 0) { this.login(val); + return; } - const skipAutoLogin = params.get('skipAutoLogin') === 'true'; - this.oidcService.settings$.subscribe(cfg => { - if (!cfg) return; - - this.oidcEnabled = cfg.authority !== ""; - this.cdRef.markForCheck(); - - if (cfg.autoLogin && !skipAutoLogin) { - this.oidcService.oidcLogin() - } - }); + this.skipAutoLogin.set(params.get('skipAutoLogin') === 'true') }); } @@ -104,33 +116,18 @@ export class UserLoginComponent implements OnInit { login(apiKey: string = '') { const model = this.loginForm.getRawValue(); model.apiKey = apiKey; - this.isSubmitting = true; - this.cdRef.markForCheck(); - this.accountService.login(model).subscribe(() => { - this.loginForm.reset(); - this.doLogin() + this.isSubmitting.set(true); + this.accountService.login(model).subscribe({ + next: () => { + this.loginForm.reset(); + this.navService.handleLogin() - this.isSubmitting = false; - this.cdRef.markForCheck(); - }, err => { - this.toastr.error(err.error); - this.isSubmitting = false; - this.cdRef.markForCheck(); + this.isSubmitting.set(false); + }, + error: (err) => { + this.toastr.error(err.error); + this.isSubmitting.set(false); + } }); } - - private doLogin() { - this.navService.showNavBar(); - this.navService.showSideNav(); - - // Check if user came here from another url, else send to library route - const pageResume = localStorage.getItem('kavita--auth-intersection-url'); - if (pageResume && pageResume !== '/login') { - localStorage.setItem('kavita--auth-intersection-url', ''); - this.router.navigateByUrl(pageResume); - } else { - localStorage.setItem('kavita--auth-intersection-url', ''); - this.router.navigateByUrl('/home'); - } - } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index aa4d78e5c..a2e7bcd45 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -13,13 +13,6 @@ "oidc": { "title": "OpenID Connect Callback", "login": "Back to login screen", - "errors": { - "missing-external-id": "OpenID Connect provider did not return a valid identifier", - "missing-email": "OpenID Connect provider did not return a valid email", - "email-not-verified": "Your email must be verified to allow logging in via OpenID Connect", - "no-account": "No matching account found", - "disabled-account": "This account is disabled, please contact an administrator" - }, "settings": { "save": "{{common.save}}", "notice": "Notice", @@ -2455,7 +2448,14 @@ "invalid-password-reset-url": "Invalid reset password url", "delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete", "theme-manual-upload": "There was an issue creating Theme from manual upload", - "theme-already-in-use": "Theme already exists by that name" + "theme-already-in-use": "Theme already exists by that name", + "oidc": { + "missing-external-id": "OpenID Connect provider did not return a valid identifier", + "missing-email": "OpenID Connect provider did not return a valid email", + "email-not-verified": "Your email must be verified to allow logging in via OpenID Connect", + "no-account": "No matching account found", + "disabled-account": "This account is disabled, please contact an administrator" + } }, "metadata-builder": { From 5480df4cfb3c01d26b500da6c3e3af4eb0047402 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 29 Jun 2025 22:27:08 +0200 Subject: [PATCH 03/27] Disable strictDiscoveryDocumentValidation as some OIDC providers don't follow this --- UI/Web/src/app/_services/oidc.service.ts | 5 +++++ UI/Web/src/assets/langs/en.json | 1 + 2 files changed, 6 insertions(+) diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 17048cc33..0f7881494 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -7,6 +7,8 @@ import {OidcPublicConfig} from "../admin/_models/oidc-config"; import {AccountService} from "./account.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {take} from "rxjs/operators"; +import {ToastrService} from "ngx-toastr"; +import {translate} from "@jsverse/transloco"; @Injectable({ providedIn: 'root' @@ -17,6 +19,7 @@ export class OidcService { private readonly httpClient = inject(HttpClient); private readonly accountService = inject(AccountService); private readonly destroyRef = inject(DestroyRef); + private readonly toastR = inject(ToastrService); baseUrl = environment.apiUrl; @@ -52,6 +55,7 @@ export class OidcService { showDebugInformation: !environment.production, responseType: 'code', scope: "openid profile email roles offline_access", + strictDiscoveryDocumentValidation: false, }); this._settings.set(oidcSetting); this.oauth2.setupAutomaticSilentRefresh(); @@ -75,6 +79,7 @@ export class OidcService { }, error: error => { console.log(error); + this.toastR.error(translate("oidc.error-loading-info")) } }); }) diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index a2e7bcd45..c6a510e13 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -13,6 +13,7 @@ "oidc": { "title": "OpenID Connect Callback", "login": "Back to login screen", + "error-loading-info": "An error occurred loading OpenID Connect info, contact your administrator", "settings": { "save": "{{common.save}}", "notice": "Notice", From 1180d518a2f91c07193663e62a0fc2a7388c1119 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:33:10 +0200 Subject: [PATCH 04/27] Authority url validator --- API.Tests/Services/SettingsServiceTests.cs | 2 +- API/Controllers/OidcControlller.cs | 18 +++++++++-- API/Extensions/IdentityServiceExtensions.cs | 10 ++++-- API/Services/OidcService.cs | 22 +++++++++++-- API/Services/SettingsService.cs | 19 ++++++++--- .../manage-open-idconnect.component.html | 8 +++++ .../manage-open-idconnect.component.ts | 32 +++++++++++++++++-- UI/Web/src/app/admin/settings.service.ts | 5 +++ UI/Web/src/assets/langs/en.json | 3 +- 9 files changed, 103 insertions(+), 16 deletions(-) diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs index a3c6b67b8..fc5dd2b25 100644 --- a/API.Tests/Services/SettingsServiceTests.cs +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -27,7 +27,7 @@ public class SettingsServiceTests _mockUnitOfWork = Substitute.For(); _settingsService = new SettingsService(_mockUnitOfWork, ds, Substitute.For(), Substitute.For(), - Substitute.For>()); + Substitute.For>(), Substitute.For()); } #region UpdateMetadataSettings diff --git a/API/Controllers/OidcControlller.cs b/API/Controllers/OidcControlller.cs index 2ccc570e3..e0ba62bcc 100644 --- a/API/Controllers/OidcControlller.cs +++ b/API/Controllers/OidcControlller.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Settings; +using API.Services; using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,10 +9,11 @@ using Microsoft.Extensions.Logging; namespace API.Controllers; -[AllowAnonymous] -public class OidcController(ILogger logger, IUnitOfWork unitOfWork, IMapper mapper): BaseApiController +public class OidcController(ILogger logger, IUnitOfWork unitOfWork, + IMapper mapper, ISettingsService settingsService): BaseApiController { + [AllowAnonymous] [HttpGet("config")] public async Task> GetOidcConfig() { @@ -19,4 +21,16 @@ public class OidcController(ILogger logger, IUnitOfWork unitOfWo return Ok(mapper.Map(settings.OidcConfig)); } + [Authorize("RequireAdminRole")] + [HttpPost("is-valid-authority")] + public async Task> IsValidAuthority([FromBody] IsValidAuthorityBody authority) + { + return Ok(await settingsService.IsValidAuthority(authority.Authority)); + } + + public class IsValidAuthorityBody + { + public string Authority { get; set; } + } + } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 6bf253d1f..8ed9c3e50 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -141,7 +141,7 @@ public static class IdentityServiceExtensions options.Events = new JwtBearerEvents { - OnMessageReceived = SetTokenFromQuery + OnMessageReceived = SetTokenFromQuery, }; }); @@ -164,8 +164,12 @@ public static class IdentityServiceExtensions if (ctx.Principal == null) return; var user = await oidcService.LoginOrCreate(ctx.Principal); - if (user == null) return; - + if (user == null) + { + ctx.Principal = null; + await ctx.HttpContext.SignOutAsync(OpenIdConnect); + return; + } var claims = new List { diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 9ddd239e3..605294379 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -27,6 +27,11 @@ public interface IOidcService /// /// if any requirements aren't met Task LoginOrCreate(ClaimsPrincipal principal); + /// + /// Remove from all users + /// + /// + Task ClearOidcIds(); } public class OidcService(ILogger logger, UserManager userManager, @@ -46,7 +51,7 @@ public class OidcService(ILogger logger, UserManager userM var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); if (user != null) { - //await SyncUserSettings(settings, principal, user); + // await SyncUserSettings(settings, principal, user); return user; } @@ -64,7 +69,7 @@ public class OidcService(ILogger logger, UserManager userM user.ExternalId = externalId; - //await SyncUserSettings(settings, principal, user); + await SyncUserSettings(settings, principal, user); var roles = await userManager.GetRolesAsync(user); if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) @@ -73,6 +78,17 @@ public class OidcService(ILogger logger, UserManager userM return user; } + public async Task ClearOidcIds() + { + var users = await unitOfWork.UserRepository.GetAllUsersAsync(); + foreach (var user in users) + { + user.ExternalId = null; + } + + await unitOfWork.CommitAsync(); + } + private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal) { if (!settings.ProvisionAccounts) return null; @@ -133,10 +149,12 @@ public class OidcService(ILogger logger, UserManager userM var userRoles = await userManager.GetRolesAsync(user); if (userRoles.Contains(PolicyConstants.AdminRole)) return; + await SyncRoles(claimsPrincipal, user); await SyncLibraries(claimsPrincipal, user); SyncAgeRating(claimsPrincipal, user); + if (unitOfWork.HasChanges()) await unitOfWork.CommitAsync(); } diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 51c02d659..8aa8204ff 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -17,6 +17,7 @@ using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace API.Services; @@ -24,6 +25,12 @@ public interface ISettingsService { Task UpdateMetadataSettings(MetadataSettingsDto dto); Task UpdateSettings(ServerSettingDto updateSettingsDto); + /// + /// Check if the server can reach the authority at the given uri + /// + /// + /// + Task IsValidAuthority(string authority); } @@ -34,16 +41,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; } /// @@ -347,7 +356,7 @@ public class SettingsService : ISettingsService return updateSettingsDto; } - private async Task IsValidAuthority(string authority) + public async Task IsValidAuthority(string authority) { if (string.IsNullOrEmpty(authority)) { @@ -357,8 +366,8 @@ public class SettingsService : ISettingsService var url = authority + "/.well-known/openid-configuration"; try { - var resp = await url.GetAsync(); - return resp.StatusCode == 200; + await url.GetJsonAsync(); + return true; } catch (Exception e) { @@ -413,6 +422,8 @@ public class SettingsService : ISettingsService setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty; Configuration.OidcAuthority = setting.Value; _unitOfWork.SettingsRepository.Update(setting); + + await _oidcService.ClearOidcIds(); } if (setting.Key == ServerSettingKey.OidcClientId && diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index 92945cef6..0e56c155a 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -21,6 +21,14 @@ + + @if (settingsForm.dirty || !settingsForm.untouched) { +
+ @if (formControl.errors?.invalidUri) { +
{{t('invalidUri')}}
+ } +
+ } } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index 6f1bf569f..ca090938b 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -2,6 +2,8 @@ import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {ServerSettings} from "../_models/server-settings"; import { + AbstractControl, + AsyncValidatorFn, FormControl, FormGroup, ReactiveFormsModule, @@ -12,6 +14,7 @@ import {SettingsService} from "../settings.service"; import {OidcConfig} from "../_models/oidc-config"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import {map, of} from "rxjs"; @Component({ selector: 'app-manage-open-idconnect', @@ -42,9 +45,7 @@ export class ManageOpenIDConnectComponent implements OnInit { this.serverSettings = data; this.oidcSettings = this.serverSettings.oidcConfig; - - // TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable - this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, [])); + this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, [], [this.authorityValidator()])); this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')])); this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, [])); this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); @@ -72,6 +73,31 @@ export class ManageOpenIDConnectComponent implements OnInit { }) } + authorityValidator(): AsyncValidatorFn { + return (control: AbstractControl) => { + let uri: string = control.value; + if (!uri || uri.trim().length === 0) { + return of(null); + } + + try { + new URL(uri); + } catch { + return of({'invalidUri': {'uri': uri}} as ValidationErrors) + } + + if (uri.endsWith('/')) { + uri = uri.substring(0, uri.length - 1); + } + + return this.settingsService.ifValidAuthority(uri).pipe(map(ok => { + if (ok) return null; + + return {'invalidUri': {'uri': uri}} as ValidationErrors; + })); + } + } + requiredIf(other: string): ValidatorFn { return (control): ValidationErrors | null => { const otherControl = this.settingsForm.get(other); diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index dc490beb7..c7bb6cf92 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -78,6 +78,11 @@ export class SettingsService { isValidCronExpression(val: string) { if (val === '' || val === undefined || val === null) return of(false); return this.http.get(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true')); + } + ifValidAuthority(authority: string) { + if (authority === '' || authority === undefined || authority === null) return of(false); + + return this.http.post(this.baseUrl + 'oidc/is-valid-authority', {authority}); } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c6a510e13..3af3db6ef 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -17,10 +17,11 @@ "settings": { "save": "{{common.save}}", "notice": "Notice", - "restart-required": "Changing OpenID Connect settings requires a restart", + "restart-required": "Changing OpenID Connect Authority requires a restart", "provider": "Provider", "behaviour": "Behaviour", "field-required": "{{name}} is required when {{other}} is set", + "invalidUri": "The provider URL is not valid", "authority": "Authority", "authority-tooltip": "The URL to your OpenID Connect provider", From 188020597c56feda475fe3e4837367baa317732e Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:54:36 +0200 Subject: [PATCH 05/27] Disable password auth setting --- API/Controllers/AccountController.cs | 13 +++- API/DTOs/Settings/OidcConfigDto.cs | 39 +++++----- API/DTOs/Settings/OidcPublicConfigDto.cs | 2 + API/Data/Seed.cs | 1 + API/Entities/Enums/ServerSettingKey.cs | 5 ++ .../Converters/ServerSettingConverter.cs | 4 ++ API/I18N/en.json | 1 + API/Services/OidcService.cs | 19 +++-- API/Services/SettingsService.cs | 13 +++- UI/Web/src/app/_services/account.service.ts | 4 +- UI/Web/src/app/_services/oidc.service.ts | 4 +- UI/Web/src/app/admin/_models/oidc-config.ts | 2 + .../manage-open-idconnect.component.html | 12 ++++ .../manage-open-idconnect.component.ts | 1 + UI/Web/src/app/app.component.ts | 3 +- .../user-login/user-login.component.html | 71 ++++++++++++------- .../user-login/user-login.component.scss | 6 +- .../user-login/user-login.component.ts | 31 +++++--- .../src/assets/icons/open-id-connect-logo.svg | 1 + UI/Web/src/assets/langs/en.json | 7 +- 20 files changed, 164 insertions(+), 75 deletions(-) create mode 100644 UI/Web/src/assets/icons/open-id-connect-logo.svg diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index eb975cde8..7090e402a 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -52,6 +52,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 +61,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 +74,7 @@ public class AccountController : BaseApiController _emailService = emailService; _eventHub = eventHub; _localizationService = localizationService; + _oidcService = oidcService; } [HttpGet] @@ -80,6 +83,9 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences); if (user == null) throw new UnauthorizedAccessException(); + var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + await _oidcService.SyncUserSettings(oidcSettings, User, user); + var roles = await _userManager.GetRolesAsync(user); if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); @@ -236,6 +242,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 funcitonal, 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 diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs index a1c2eb299..5930ae411 100644 --- a/API/DTOs/Settings/OidcConfigDto.cs +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -1,32 +1,27 @@ #nullable enable +using API.Entities.Enums; + namespace API.DTOs.Settings; public class OidcConfigDto { - /// - /// Base url for authority, must have /.well-known/openid-configuration - /// + /// public string? Authority { get; set; } + /// + public string? ClientId { get; set; } + /// + public bool ProvisionAccounts { get; set; } + /// + public bool RequireVerifiedEmail { get; set; } + /// + public bool ProvisionUserSettings { get; set; } + /// + public bool AutoLogin { get; set; } + /// + public bool DisablePasswordAuthentication { get; set; } /// - /// ClientId configured in your OpenID Connect provider + /// Returns true if the has been set /// - public string? ClientId { get; set; } - /// - /// Create a new account when someone logs in with an unmatched account, if is true, - /// will account will be verified by default - /// - public bool ProvisionAccounts { get; set; } - /// - /// Require emails from OpenIDConnect to be verified before use - /// - public bool RequireVerifiedEmail { get; set; } - /// - /// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in. - /// - public bool ProvisionUserSettings { get; set; } - /// - /// Try logging in automatically when opening the app - /// - public bool AutoLogin { get; set; } + public bool Enabled => Authority != ""; } diff --git a/API/DTOs/Settings/OidcPublicConfigDto.cs b/API/DTOs/Settings/OidcPublicConfigDto.cs index 73bb8ee77..59498fcd6 100644 --- a/API/DTOs/Settings/OidcPublicConfigDto.cs +++ b/API/DTOs/Settings/OidcPublicConfigDto.cs @@ -9,4 +9,6 @@ public sealed record OidcPublicConfigDto public string? ClientId { get; set; } /// public bool AutoLogin { get; set; } + /// + public bool DisablePasswordAuthentication { get; set; } } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index b6c9d65bd..555a24e14 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -258,6 +258,7 @@ public static class Seed new() { Key = ServerSettingKey.OidcProvisionAccounts, Value = "false"}, new() { Key = ServerSettingKey.OidcRequireVerifiedEmail, Value = "true"}, new() { Key = ServerSettingKey.OidcProvisionUserSettings, Value = "false"}, + new() { Key = ServerSettingKey.DisablePasswordAuthentication, Value = "false"}, new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 7f0801b5d..46bde5325 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -227,4 +227,9 @@ public enum ServerSettingKey ///
[Description("OpenIDConnectSyncUserSettings")] OidcProvisionUserSettings = 45, + /// + /// Disables password authentication for non-admin users + /// + [Description("DisablePasswordAuthentication")] + DisablePasswordAuthentication = 46, } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 4e897354a..4a65641dd 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -153,6 +153,10 @@ public class ServerSettingConverter : ITypeConverter, destination.OidcConfig ??= new OidcConfigDto(); destination.OidcConfig.ProvisionUserSettings = bool.Parse(row.Value); break; + case ServerSettingKey.DisablePasswordAuthentication: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.DisablePasswordAuthentication = bool.Parse(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..b50c09051 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", diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 605294379..f5f8f59c3 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -28,6 +28,13 @@ public interface IOidcService /// if any requirements aren't met Task LoginOrCreate(ClaimsPrincipal principal); /// + /// Updates roles, library access and age rating. Does not assign admin role, or to admin roles + /// + /// + /// + /// + Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user); + /// /// Remove from all users /// /// @@ -51,7 +58,6 @@ public class OidcService(ILogger logger, UserManager userM var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); if (user != null) { - // await SyncUserSettings(settings, principal, user); return user; } @@ -135,14 +141,7 @@ public class OidcService(ILogger logger, UserManager userM return user; } - /// - /// Updates roles, library access and age rating. Does not assign admin role, or to admin roles - /// - /// - /// - /// - /// Extra feature, little buggy for now - private async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) + public async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) { if (!settings.ProvisionUserSettings) return; @@ -196,7 +195,7 @@ public class OidcService(ILogger logger, UserManager userM .ToList(); if (ageRatings.Count == 0) return; - var highestAgeRating = AgeRating.NotApplicable; + var highestAgeRating = AgeRating.Unknown; foreach (var ar in ageRatings) { diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 8aa8204ff..adb3672fe 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -366,8 +366,10 @@ public class SettingsService : ISettingsService var url = authority + "/.well-known/openid-configuration"; try { - await url.GetJsonAsync(); - return true; + //await url.GetJsonAsync(); + //return true; + var res = await url.GetAsync(); + return res.StatusCode == 200; } catch (Exception e) { @@ -455,6 +457,13 @@ public class SettingsService : ISettingsService _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.DisablePasswordAuthentication && + updateSettingsDto.OidcConfig.DisablePasswordAuthentication + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OidcConfig.DisablePasswordAuthentication + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 6f949d33f..52a618fc5 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -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,6 +63,8 @@ export class AccountService { return this.hasAdminRole(u); }), shareReplay({bufferSize: 1, refCount: true})); + public currentUserSignal = toSignal(this.currentUserSource); + /** diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 0f7881494..1efaeb348 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -91,7 +91,9 @@ export class OidcService { } logout() { - this.oauth2.logOut(); + if (this.token) { + this.oauth2.logOut(); + } } config() { diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index ab94dde68..77f9bd421 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -6,10 +6,12 @@ export interface OidcConfig { requireVerifiedEmail: boolean; provisionUserSettings: boolean; autoLogin: boolean; + disablePasswordAuthentication: boolean; } export interface OidcPublicConfig { authority: string; clientId: string; autoLogin: boolean; + disablePasswordAuthentication: boolean; } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index 0e56c155a..4d7486152 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -112,6 +112,18 @@ }
+
+ @if(settingsForm.get('disablePasswordAuthentication'); as formControl) { + + +
+ +
+
+
+ } +
+ diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index ca090938b..66e9bb471 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -51,6 +51,7 @@ export class ManageOpenIDConnectComponent implements OnInit { this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, [])); this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, [])); + this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, [])); this.cdRef.markForCheck(); } }) diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 20e71b534..5fcad6f8e 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -103,7 +103,8 @@ export class AppComponent implements OnInit { // Login automatically when a token is available effect(() => { const ready = this.oidcService.ready(); - if (!ready || !this.oidcService.token) return; + const user = this.accountService.currentUserSignal(); + if (!ready || !this.oidcService.token || user) return; this.accountService.loginByToken(this.oidcService.token).subscribe({ next: () => { diff --git a/UI/Web/src/app/registration/user-login/user-login.component.html b/UI/Web/src/app/registration/user-login/user-login.component.html index 44b850a4d..29c100989 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.html +++ b/UI/Web/src/app/registration/user-login/user-login.component.html @@ -1,37 +1,58 @@ - +

{{t('title')}}

- -
-
-
- - -
-
- - -
+ @if (isLoaded()) { + @if (showPasswordLogin() && !firstTimeFlow()) { + +
+
+ + +
- +
+ + +
- -
- + - @if (oidcService.ready()) { - + +
+ } -
+ @if (oidcService.ready()) { + + } + + @if (!showPasswordLogin()) { +
+ {{t('bypass')}} + {{t('here')}} +
+ } + }
diff --git a/UI/Web/src/app/registration/user-login/user-login.component.scss b/UI/Web/src/app/registration/user-login/user-login.component.scss index d1d755d33..3c349dc27 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.scss +++ b/UI/Web/src/app/registration/user-login/user-login.component.scss @@ -45,4 +45,8 @@ a { .btn { font-family: var(--login-input-font-family); } -} \ No newline at end of file +} + +.text-muted { + font-size: 0.8rem; +} diff --git a/UI/Web/src/app/registration/user-login/user-login.component.ts b/UI/Web/src/app/registration/user-login/user-login.component.ts index 7b760a70a..413aa3c5b 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.ts +++ b/UI/Web/src/app/registration/user-login/user-login.component.ts @@ -1,27 +1,24 @@ import { - AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, - DestroyRef, effect, inject, + effect, inject, OnInit, signal } from '@angular/core'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import {ActivatedRoute, Router, RouterLink} from '@angular/router'; -import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { AccountService } from '../../_services/account.service'; import { MemberService } from '../../_services/member.service'; import { NavService } from '../../_services/nav.service'; -import { NgIf } from '@angular/common'; +import {NgOptimizedImage} from '@angular/common'; import { SplashContainerComponent } from '../_components/splash-container/splash-container.component'; -import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; import {environment} from "../../../environments/environment"; import {OidcService} from "../../_services/oidc.service"; -import {forkJoin} from "rxjs"; -import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; @Component({ @@ -29,7 +26,7 @@ import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; templateUrl: './user-login.component.html', styleUrls: ['./user-login.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip] + imports: [SplashContainerComponent, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip, NgOptimizedImage] }) export class UserLoginComponent implements OnInit { @@ -61,7 +58,23 @@ export class UserLoginComponent implements OnInit { /** * undefined until query params are read */ - skipAutoLogin = signal(undefined) + skipAutoLogin = signal(undefined); + + /** + * Display the login form, regardless if the password authentication is disabled (admins can still log in) + */ + forceShowPasswordLogin = signal(false); + /** + * Display the login form + */ + showPasswordLogin = computed(() => { + const loaded = this.isLoaded(); + const config = this.oidcService.settings(); + const force = this.forceShowPasswordLogin(); + if (force) return true; + + return loaded && config && !config.disablePasswordAuthentication; + }); constructor() { this.navService.hideNavBar(); diff --git a/UI/Web/src/assets/icons/open-id-connect-logo.svg b/UI/Web/src/assets/icons/open-id-connect-logo.svg new file mode 100644 index 000000000..482268c4c --- /dev/null +++ b/UI/Web/src/assets/icons/open-id-connect-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 3af3db6ef..cc572a46e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -7,7 +7,8 @@ "forgot-password": "Forgot Password?", "submit": "Sign in", "oidc": "Log in with OpenID Connect", - "oidc-tooltip": "This will connect you to an external site" + "bypass": "Password login has been disabled. Bypass ", + "here": "here" }, "oidc": { @@ -34,7 +35,9 @@ "provisionUserSettings": "Provision user settings", "provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information", "autoLogin": "Auto login", - "autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen" + "autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen", + "disablePasswordAuthentication": "Disable password authentication", + "disablePasswordAuthentication-tooltip": "Users with the admin role can bypass this restriction" } }, From 9f94abe1be7dc5377988a63b4ca3b62f8fa1ad7a Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:52:49 +0200 Subject: [PATCH 06/27] Ensure default reading profile is created --- API/Services/OidcService.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index f5f8f59c3..42ae53a7c 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -127,6 +127,7 @@ public class OidcService(ILogger logger, UserManager userM } AddDefaultStreamsToUser(user, mapper); + await AddDefaultReadingProfileToUser(user); if (settings.RequireVerifiedEmail) { @@ -223,4 +224,14 @@ public class OidcService(ILogger logger, UserManager userM 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(); + } } From 54fb4c7a8a7c00b39f6a7b95c04196fb57a9d3e0 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:08:49 +0200 Subject: [PATCH 07/27] Use roles to sync Libraries and Age Rating for simplicty --- API/Extensions/StringExtensions.cs | 4 ++-- API/Services/OidcService.cs | 14 ++++++++------ API/Services/SettingsService.cs | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index d5467482e..e62983c84 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -53,9 +53,9 @@ public static class StringExtensions return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture); } - public static string? TrimPrefix(this string? value, string prefix) + public static string TrimPrefix(this string? value, string prefix) { - if (string.IsNullOrEmpty(value)) return value; + if (string.IsNullOrEmpty(value)) return string.Empty; if (!value.StartsWith(prefix)) return value; diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 42ae53a7c..6f1fac98b 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -44,8 +44,8 @@ public interface IOidcService public class OidcService(ILogger logger, UserManager userManager, IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService { - private const string LibraryAccessClaim = "library"; - private const string AgeRatingClaim = "AgeRating"; + private const string LibraryAccessPrefix = "library-"; + private const string AgeRatingPrefix = "age-rating-"; public async Task LoginOrCreate(ClaimsPrincipal principal) { @@ -175,8 +175,9 @@ public class OidcService(ILogger logger, UserManager userM private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) { var libraryAccess = claimsPrincipal - .FindAll(LibraryAccessClaim) - .Select(r => r.Value) + .FindAll(ClaimTypes.Role) + .Where(r => r.Value.StartsWith(LibraryAccessPrefix)) + .Select(r => r.Value.TrimPrefix(LibraryAccessPrefix)) .ToList(); if (libraryAccess.Count == 0) return; @@ -191,8 +192,9 @@ public class OidcService(ILogger logger, UserManager userM { var ageRatings = claimsPrincipal - .FindAll(AgeRatingClaim) - .Select(r => r.Value) + .FindAll(ClaimTypes.Role) + .Where(r => r.Value.StartsWith(AgeRatingPrefix)) + .Select(r => r.Value.TrimPrefix(AgeRatingPrefix)) .ToList(); if (ageRatings.Count == 0) return; diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index adb3672fe..ebcb987c3 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -373,7 +373,7 @@ public class SettingsService : ISettingsService } catch (Exception e) { - _logger.LogError(e, "OpenIdConfiguration failed: {Reason}", e.Message); + _logger.LogTrace(e, "OpenIdConfiguration failed: {Reason}", e.Message); return false; } } From 4c0faa755dd47e4ee6b4110b638c8ae241e00891 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:27:53 +0200 Subject: [PATCH 08/27] Cleanup login page, custom button text --- API/DTOs/Settings/OidcConfigDto.cs | 11 ++------- API/DTOs/Settings/OidcPublicConfigDto.cs | 14 +++++++---- API/Data/Seed.cs | 1 + API/Entities/Enums/ServerSettingKey.cs | 6 +++++ .../Converters/ServerSettingConverter.cs | 4 ++++ API/Services/SettingsService.cs | 7 ++++++ UI/Web/src/app/admin/_models/oidc-config.ts | 2 ++ .../manage-open-idconnect.component.html | 17 +++++++++++++- .../manage-open-idconnect.component.ts | 23 ++++++++++++++++--- .../user-login/user-login.component.html | 9 +------- .../user-login/user-login.component.ts | 3 ++- UI/Web/src/assets/langs/en.json | 11 +++++---- 12 files changed, 76 insertions(+), 32 deletions(-) diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs index 5930ae411..9e7270c2b 100644 --- a/API/DTOs/Settings/OidcConfigDto.cs +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -3,12 +3,8 @@ using API.Entities.Enums; namespace API.DTOs.Settings; -public class OidcConfigDto +public record OidcConfigDto: OidcPublicConfigDto { - /// - public string? Authority { get; set; } - /// - public string? ClientId { get; set; } /// public bool ProvisionAccounts { get; set; } /// @@ -16,12 +12,9 @@ public class OidcConfigDto /// public bool ProvisionUserSettings { get; set; } /// - public bool AutoLogin { get; set; } - /// - public bool DisablePasswordAuthentication { get; set; } /// - /// Returns true if the has been set + /// Returns true if the has been set /// public bool Enabled => Authority != ""; } diff --git a/API/DTOs/Settings/OidcPublicConfigDto.cs b/API/DTOs/Settings/OidcPublicConfigDto.cs index 59498fcd6..b94ca3884 100644 --- a/API/DTOs/Settings/OidcPublicConfigDto.cs +++ b/API/DTOs/Settings/OidcPublicConfigDto.cs @@ -1,14 +1,18 @@ #nullable enable +using API.Entities.Enums; + namespace API.DTOs.Settings; -public sealed record OidcPublicConfigDto +public record OidcPublicConfigDto { - /// + /// public string? Authority { get; set; } - /// + /// public string? ClientId { get; set; } - /// + /// public bool AutoLogin { get; set; } - /// + /// public bool DisablePasswordAuthentication { get; set; } + /// + public string ProviderName { get; set; } = string.Empty; } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 555a24e14..9776cdda7 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -259,6 +259,7 @@ public static class Seed new() { Key = ServerSettingKey.OidcRequireVerifiedEmail, Value = "true"}, new() { Key = ServerSettingKey.OidcProvisionUserSettings, Value = "false"}, new() { Key = ServerSettingKey.DisablePasswordAuthentication, Value = "false"}, + new() { Key = ServerSettingKey.OidcProviderName, Value = "OpenID Connect"}, new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 46bde5325..ed53d0a37 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -232,4 +232,10 @@ public enum ServerSettingKey /// [Description("DisablePasswordAuthentication")] DisablePasswordAuthentication = 46, + /// + /// Name of your provider, used to display on the login screen + /// + /// Default to OpenID Connect + [Description("OidcProviderName")] + OidcProviderName = 47, } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 4a65641dd..9fdc36e6c 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -157,6 +157,10 @@ public class ServerSettingConverter : ITypeConverter, destination.OidcConfig ??= new OidcConfigDto(); destination.OidcConfig.DisablePasswordAuthentication = bool.Parse(row.Value); break; + case ServerSettingKey.OidcProviderName: + destination.OidcConfig ??= new OidcConfigDto(); + destination.OidcConfig.ProviderName = row.Value; + break; case ServerSettingKey.LicenseKey: case ServerSettingKey.EnableAuthentication: case ServerSettingKey.EmailServiceUrl: diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index ebcb987c3..73ceba1ab 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -464,6 +464,13 @@ public class SettingsService : ISettingsService _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.OidcProviderName && + updateSettingsDto.OidcConfig.ProviderName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OidcConfig.ProviderName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index 77f9bd421..8b8ca5d1d 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -7,6 +7,7 @@ export interface OidcConfig { provisionUserSettings: boolean; autoLogin: boolean; disablePasswordAuthentication: boolean; + providerName: string; } export interface OidcPublicConfig { @@ -14,4 +15,5 @@ export interface OidcPublicConfig { clientId: string; autoLogin: boolean; disablePasswordAuthentication: boolean; + providerName: string; } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index 4d7486152..fd5303b8a 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -10,6 +10,7 @@

{{t('provider')}}

+
{{t('manual-save')}}
@if (settingsForm.get('authority'); as formControl) { @@ -62,8 +63,22 @@

{{t('behaviour')}}

- +
+ @if (settingsForm.get('providerName'); as formControl) { + + + {{formControl.value}} + + + + + + } +
+
@if(settingsForm.get('provisionAccounts'); as formControl) { diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index 66e9bb471..214722944 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; +import {ChangeDetectorRef, Component, DestroyRef, OnInit} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {ServerSettings} from "../_models/server-settings"; import { @@ -14,7 +14,8 @@ import {SettingsService} from "../settings.service"; import {OidcConfig} from "../_models/oidc-config"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; -import {map, of} from "rxjs"; +import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-manage-open-idconnect', @@ -36,6 +37,7 @@ export class ManageOpenIDConnectComponent implements OnInit { constructor( private settingsService: SettingsService, private cdRef: ChangeDetectorRef, + private destroyRef: DestroyRef, ) { } @@ -52,12 +54,27 @@ export class ManageOpenIDConnectComponent implements OnInit { this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, [])); this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, [])); this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, [])); + this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, [])); this.cdRef.markForCheck(); + + this.settingsForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef), + filter(() => { + // Do not auto save when provider settings have changed + const settings: OidcConfig = this.settingsForm.getRawValue(); + return settings.authority == this.oidcSettings.authority && settings.clientId == this.oidcSettings.clientId; + }), + tap(() => this.save()) + ).subscribe(); } - }) + }); } save() { + if (!this.settingsForm.valid) return; + const data = this.settingsForm.getRawValue(); const newSettings = Object.assign({}, this.serverSettings); newSettings.oidcConfig = data as OidcConfig; diff --git a/UI/Web/src/app/registration/user-login/user-login.component.html b/UI/Web/src/app/registration/user-login/user-login.component.html index 29c100989..4649f9447 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.html +++ b/UI/Web/src/app/registration/user-login/user-login.component.html @@ -42,16 +42,9 @@ class="d-inline-block" style="object-fit: contain;" /> - {{t('oidc')}} + {{oidcService.settings()?.providerName || t('oidc')}} } - - @if (!showPasswordLogin()) { -
- {{t('bypass')}} - {{t('here')}} -
- } } diff --git a/UI/Web/src/app/registration/user-login/user-login.component.ts b/UI/Web/src/app/registration/user-login/user-login.component.ts index 413aa3c5b..ea4a1715c 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.ts +++ b/UI/Web/src/app/registration/user-login/user-login.component.ts @@ -59,9 +59,9 @@ export class UserLoginComponent implements OnInit { * undefined until query params are read */ skipAutoLogin = signal(undefined); - /** * Display the login form, regardless if the password authentication is disabled (admins can still log in) + * Set from query */ forceShowPasswordLogin = signal(false); /** @@ -121,6 +121,7 @@ export class UserLoginComponent implements OnInit { } this.skipAutoLogin.set(params.get('skipAutoLogin') === 'true') + this.forceShowPasswordLogin.set(params.get('forceShowPassword') === 'true'); }); } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index cc572a46e..407b2cbdb 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -6,9 +6,7 @@ "password-validation": "{{validation.password-validation}}", "forgot-password": "Forgot Password?", "submit": "Sign in", - "oidc": "Log in with OpenID Connect", - "bypass": "Password login has been disabled. Bypass ", - "here": "here" + "oidc": "OpenID Connect" }, "oidc": { @@ -18,11 +16,12 @@ "settings": { "save": "{{common.save}}", "notice": "Notice", - "restart-required": "Changing OpenID Connect Authority requires a restart", + "restart-required": "Changing Authority, or Client ID requires a manual restart of Kavita to take effect.", "provider": "Provider", "behaviour": "Behaviour", "field-required": "{{name}} is required when {{other}} is set", "invalidUri": "The provider URL is not valid", + "manual-save": "Changing provider settings requires a manual save", "authority": "Authority", "authority-tooltip": "The URL to your OpenID Connect provider", @@ -37,7 +36,9 @@ "autoLogin": "Auto login", "autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen", "disablePasswordAuthentication": "Disable password authentication", - "disablePasswordAuthentication-tooltip": "Users with the admin role can bypass this restriction" + "disablePasswordAuthentication-tooltip": "Users with the admin role can bypass this restriction", + "providerName": "Provider name", + "providerName-tooltip": "Name show on the login screen" } }, From 7847ce4c1b084c3ddb8fe4fec39411d921334884 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 30 Jun 2025 21:51:13 +0200 Subject: [PATCH 09/27] Ensure side nav streams aren't duplicated in sync --- API/Controllers/AccountController.cs | 2 +- API/Services/AccountService.cs | 8 ++++++++ API/Services/OidcService.cs | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 7090e402a..6979da23d 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -80,7 +80,7 @@ public class AccountController : BaseApiController [HttpGet] public async Task> GetCurrentUserAsync() { - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); if (user == null) throw new UnauthorizedAccessException(); var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 161732faf..e331030ee 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -31,6 +31,14 @@ public interface IAccountService Task HasDownloadPermission(AppUser? user); Task CanChangeAgeRestriction(AppUser? user); + /// + /// + /// + /// + /// + /// + /// + /// Ensure that the users SideNavStreams are loaded Task UpdateLibrariesForUser(AppUser user, IList librariesIds, bool hasAdminRole); Task> UpdateRolesForUser(AppUser user, IList roles); } diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 6f1fac98b..33a6de272 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -69,7 +69,7 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.email-not-verified"); - user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences) + user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams) ?? await NewUserFromOpenIdConnect(settings, principal); if (user == null) return null; From 5104a66cae0a24a3deb93a928d8fdb62c28cb9c7 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:25:32 +0200 Subject: [PATCH 10/27] This can be done in Keycloak, no need to manually do it here, add missing translation --- API/Extensions/IdentityServiceExtensions.cs | 2 - API/Helpers/RolesClaimsTransformation.cs | 47 --------------------- UI/Web/src/assets/langs/en.json | 3 +- 3 files changed, 2 insertions(+), 50 deletions(-) delete mode 100644 API/Helpers/RolesClaimsTransformation.cs diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 8ed9c3e50..08644b8dc 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -97,8 +97,6 @@ public static class IdentityServiceExtensions if (Configuration.OidcEnabled) { - services.AddScoped(); - // TODO: Investigate on how to make this not hardcoded at startup auth.AddJwtBearer(OpenIdConnect, options => { diff --git a/API/Helpers/RolesClaimsTransformation.cs b/API/Helpers/RolesClaimsTransformation.cs deleted file mode 100644 index bf090bf33..000000000 --- a/API/Helpers/RolesClaimsTransformation.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Generic; -using System.Security.Claims; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Kavita.Common; -using Microsoft.AspNetCore.Authentication; - -namespace API.Helpers; - -/// -/// Adds assigned roles from Keycloak under the default claim -/// -public class RolesClaimsTransformation: IClaimsTransformation -{ - private const string ResourceAccessClaim = "resource_access"; - private string _clientId; - - public Task TransformAsync(ClaimsPrincipal principal) - { - var resourceAccess = principal.FindFirst(ResourceAccessClaim); - if (resourceAccess == null) return Task.FromResult(principal); - - var resources = JsonSerializer.Deserialize>(resourceAccess.Value); - if (resources == null) return Task.FromResult(principal); - - if (string.IsNullOrEmpty(_clientId)) - { - _clientId = Configuration.OidcClientId; - } - - var kavitaResource = resources.GetValueOrDefault(_clientId); - if (kavitaResource == null) return Task.FromResult(principal); - - foreach (var role in kavitaResource.Roles) - { - ((ClaimsIdentity)principal.Identity)?.AddClaim(new Claim(ClaimTypes.Role, role)); - } - return Task.FromResult(principal); - } - - private sealed class Resource - { - [JsonPropertyName("roles")] - public IList Roles { get; set; } = []; - } -} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 407b2cbdb..8e3f715d5 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2460,7 +2460,8 @@ "missing-email": "OpenID Connect provider did not return a valid email", "email-not-verified": "Your email must be verified to allow logging in via OpenID Connect", "no-account": "No matching account found", - "disabled-account": "This account is disabled, please contact an administrator" + "disabled-account": "This account is disabled, please contact an administrator", + "creating-user": "Failed to create a new user, please contact an administrator" } }, From e8f74709f344a99b4d7541dd6abe6024e9d0b842 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:17:38 +0200 Subject: [PATCH 11/27] Fix name check not working correctly --- API/Services/OidcService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 33a6de272..475c854e4 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -107,7 +107,7 @@ public class OidcService(ILogger logger, UserManager userM name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname); name ??= emailClaim.Value; - var other = await unitOfWork.UserRepository.GetUserByUsernameAsync(name); + var other = await userManager.FindByNameAsync(name); if (other != null) { // We match by email, so this will always be unique @@ -122,7 +122,7 @@ public class OidcService(ILogger logger, UserManager userM if (!res.Succeeded) { logger.LogError("Failed to create new user from OIDC: {Errors}", - res.Errors.Select(x => x.Description).ToString()); + res.Errors.Select(x => x.Description).ToList()); throw new KavitaException("errors.oidc.creating-user"); } From dc91696769e245927f813b6f235f04af5748aa80 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:50:59 +0200 Subject: [PATCH 12/27] Save all settings as json instead of having lots of keys --- API/DTOs/Settings/OidcConfigDto.cs | 20 +++++-- API/DTOs/Settings/OidcPublicConfigDto.cs | 15 +++-- API/Data/Seed.cs | 9 +-- API/Entities/Enums/ServerSettingKey.cs | 33 ++--------- API/Extensions/ClaimsPrincipalExtensions.cs | 13 ++++- .../Converters/ServerSettingConverter.cs | 28 ++-------- API/Services/SettingsService.cs | 56 +++++-------------- 7 files changed, 63 insertions(+), 111 deletions(-) diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs index 9e7270c2b..adb15b200 100644 --- a/API/DTOs/Settings/OidcConfigDto.cs +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -1,17 +1,25 @@ #nullable enable -using API.Entities.Enums; namespace API.DTOs.Settings; public record OidcConfigDto: OidcPublicConfigDto { - /// + /// + /// If true, auto creates a new account when someone logs in via OpenID Connect + /// public bool ProvisionAccounts { get; set; } - /// - public bool RequireVerifiedEmail { get; set; } - /// + /// + /// 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 provides roles on log in. + /// public bool ProvisionUserSettings { get; set; } - /// + /// + /// Requires roles to be configured in OIDC + /// + public bool RequireRoles { get; set; } = true; /// /// Returns true if the has been set diff --git a/API/DTOs/Settings/OidcPublicConfigDto.cs b/API/DTOs/Settings/OidcPublicConfigDto.cs index b94ca3884..171e46d7e 100644 --- a/API/DTOs/Settings/OidcPublicConfigDto.cs +++ b/API/DTOs/Settings/OidcPublicConfigDto.cs @@ -9,10 +9,17 @@ public record OidcPublicConfigDto public string? Authority { get; set; } /// public string? ClientId { get; set; } - /// + /// + /// Optional OpenID Connect ClientSecret, required if authority is set + /// public bool AutoLogin { get; set; } - /// + /// + /// Disables password authentication for non-admin users + /// public bool DisablePasswordAuthentication { get; set; } - /// - public string ProviderName { get; set; } = string.Empty; + /// + /// Name of your provider, used to display on the login screen + /// + /// Default to OpenID Connect + public string ProviderName { get; set; } = "OpenID Connect"; } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 9776cdda7..12084e20a 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; @@ -254,12 +256,7 @@ public static class Seed }, // Not used from DB, but DB is sync with appSettings.json new() { Key = ServerSettingKey.OidcAuthority, Value = Configuration.OidcAuthority }, new() { Key = ServerSettingKey.OidcClientId, Value = Configuration.OidcClientId}, - new() { Key = ServerSettingKey.OidcAutoLogin, Value = "false"}, - new() { Key = ServerSettingKey.OidcProvisionAccounts, Value = "false"}, - new() { Key = ServerSettingKey.OidcRequireVerifiedEmail, Value = "true"}, - new() { Key = ServerSettingKey.OidcProvisionUserSettings, Value = "false"}, - new() { Key = ServerSettingKey.DisablePasswordAuthentication, Value = "false"}, - new() { Key = ServerSettingKey.OidcProviderName, Value = "OpenID Connect"}, + new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())}, new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index ed53d0a37..f14e76723 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -208,34 +208,9 @@ public enum ServerSettingKey [Description("OpenIDConnectClientId")] OidcClientId = 41, /// - /// Optional OpenID Connect ClientSecret, required if authority is set + /// A Json object of type /// - [Description("OpenIdConnectAutoLogin")] - OidcAutoLogin = 42, - /// - /// If true, auto creates a new account when someone logs in via OpenID Connect - /// - [Description("OpenIDConnectCreateAccounts")] - OidcProvisionAccounts = 43, - /// - /// Require emails to be verified by the OpenID Connect provider when creating accounts on login - /// - [Description("OpenIDConnectVerifiedEmail")] - OidcRequireVerifiedEmail = 44, - /// - /// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in. - /// - [Description("OpenIDConnectSyncUserSettings")] - OidcProvisionUserSettings = 45, - /// - /// Disables password authentication for non-admin users - /// - [Description("DisablePasswordAuthentication")] - DisablePasswordAuthentication = 46, - /// - /// Name of your provider, used to display on the login screen - /// - /// Default to OpenID Connect - [Description("OidcProviderName")] - OidcProviderName = 47, + [Description("OidcConfiguration")] + OidcConfiguration = 42, + } diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index bebc4e809..c1599caaf 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; @@ -41,4 +44,12 @@ public static class ClaimsPrincipalExtensions return true; } + + public static List GetAccessRoles(this ClaimsPrincipal claimsPrincipal) + { + return claimsPrincipal.FindAll(ClaimTypes.Role) + .Select(r => r.Value) + .Where(r => PolicyConstants.ValidRoles.Contains(r)) + .ToList(); + } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 9fdc36e6c..950faba4e 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; @@ -137,29 +138,12 @@ public class ServerSettingConverter : ITypeConverter, destination.OidcConfig ??= new OidcConfigDto(); destination.OidcConfig.ClientId = row.Value; break; - case ServerSettingKey.OidcAutoLogin: + case ServerSettingKey.OidcConfiguration: destination.OidcConfig ??= new OidcConfigDto(); - destination.OidcConfig.AutoLogin = bool.Parse(row.Value); - break; - case ServerSettingKey.OidcProvisionAccounts: - destination.OidcConfig ??= new OidcConfigDto(); - destination.OidcConfig.ProvisionAccounts = bool.Parse(row.Value); - break; - case ServerSettingKey.OidcRequireVerifiedEmail: - destination.OidcConfig ??= new OidcConfigDto(); - destination.OidcConfig.RequireVerifiedEmail = bool.Parse(row.Value); - break; - case ServerSettingKey.OidcProvisionUserSettings: - destination.OidcConfig ??= new OidcConfigDto(); - destination.OidcConfig.ProvisionUserSettings = bool.Parse(row.Value); - break; - case ServerSettingKey.DisablePasswordAuthentication: - destination.OidcConfig ??= new OidcConfigDto(); - destination.OidcConfig.DisablePasswordAuthentication = bool.Parse(row.Value); - break; - case ServerSettingKey.OidcProviderName: - destination.OidcConfig ??= new OidcConfigDto(); - destination.OidcConfig.ProviderName = row.Value; + var configuration = JsonSerializer.Deserialize(row.Value)!; + configuration.Authority = destination.OidcConfig.Authority; + configuration.ClientId = destination.OidcConfig.ClientId; + destination.OidcConfig = configuration; break; case ServerSettingKey.LicenseKey: case ServerSettingKey.EnableAuthentication: diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 73ceba1ab..0719a6e8f 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net; +using System.Text.Json; using System.Threading.Tasks; using API.Data; using API.DTOs.KavitaPlus.Metadata; @@ -413,64 +414,33 @@ public class SettingsService : ISettingsService private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) { - if (setting.Key == ServerSettingKey.OidcAuthority && - updateSettingsDto.OidcConfig.Authority + string.Empty != setting.Value) + if (setting.Key == ServerSettingKey.OidcAuthority && setting.Value != updateSettingsDto.OidcConfig.Authority) { if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty)) { throw new KavitaException("oidc-invalid-authority"); } - - setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty; - Configuration.OidcAuthority = setting.Value; + setting.Value = updateSettingsDto.OidcConfig.Authority; _unitOfWork.SettingsRepository.Update(setting); - + _logger.LogWarning("OIDC Authority is changing, clearing all external ids"); await _oidcService.ClearOidcIds(); + return; } - if (setting.Key == ServerSettingKey.OidcClientId && - updateSettingsDto.OidcConfig.ClientId + string.Empty != setting.Value) + if (setting.Key == ServerSettingKey.OidcClientId && setting.Value != updateSettingsDto.OidcConfig.ClientId) { - setting.Value = updateSettingsDto.OidcConfig.ClientId + string.Empty; - Configuration.OidcClientId = setting.Value; + setting.Value = updateSettingsDto.OidcConfig.ClientId; _unitOfWork.SettingsRepository.Update(setting); + return; } - if (setting.Key == ServerSettingKey.OidcAutoLogin && - updateSettingsDto.OidcConfig.AutoLogin + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OidcConfig.AutoLogin + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } + if (setting.Key != ServerSettingKey.OidcConfiguration) return; - if (setting.Key == ServerSettingKey.OidcProvisionAccounts && - updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.OidcProvisionUserSettings && - updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.DisablePasswordAuthentication && - updateSettingsDto.OidcConfig.DisablePasswordAuthentication + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OidcConfig.DisablePasswordAuthentication + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.OidcProviderName && - updateSettingsDto.OidcConfig.ProviderName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OidcConfig.ProviderName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } + var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); + if (setting.Value == newValue) return; + setting.Value = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); + _unitOfWork.SettingsRepository.Update(setting); } private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) From 9fb29dec2033e79cada8cb74b3868dbbe8772533 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:46:39 +0200 Subject: [PATCH 13/27] Make a proper disction betwen who owns the account, preperation for actual sync --- API/Controllers/AccountController.cs | 16 + API/DTOs/Account/UpdateUserDto.cs | 2 + API/DTOs/MemberDto.cs | 2 + API/DTOs/Settings/OidcConfigDto.cs | 6 +- API/DTOs/UserDto.cs | 5 +- .../20250701154425_AppUserOwner.Designer.cs | 3728 +++++++++++++++++ .../Migrations/20250701154425_AppUserOwner.cs | 39 + .../Migrations/DataContextModelSnapshot.cs | 6 +- API/Data/Repositories/UserRepository.cs | 3 +- API/Entities/AppUser.cs | 8 +- API/Entities/Enums/AppUserOwner.cs | 16 + API/Extensions/IdentityServiceExtensions.cs | 23 +- API/I18N/en.json | 1 + API/Services/OidcService.cs | 27 +- UI/Web/src/app/_models/auth/member.ts | 2 + UI/Web/src/app/_models/user.ts | 8 + UI/Web/src/app/_pipes/user-owner.pipe.ts | 19 + UI/Web/src/app/admin/_models/oidc-config.ts | 2 +- .../admin/edit-user/edit-user.component.html | 45 +- .../admin/edit-user/edit-user.component.ts | 60 +- .../manage-open-idconnect.component.html | 6 +- .../manage-open-idconnect.component.ts | 2 +- .../manage-users/manage-users.component.html | 13 + .../manage-users/manage-users.component.ts | 20 +- UI/Web/src/assets/langs/en.json | 19 +- 25 files changed, 4021 insertions(+), 57 deletions(-) create mode 100644 API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs create mode 100644 API/Data/Migrations/20250701154425_AppUserOwner.cs create mode 100644 API/Entities/Enums/AppUserOwner.cs create mode 100644 UI/Web/src/app/_pipes/user-owner.pipe.ts diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 6979da23d..76ccd95ea 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -532,6 +532,7 @@ public class AccountController : BaseApiController /// /// /// + /// OIDC managed users cannot be edited if SyncUsers is enabled [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] public async Task UpdateAccount(UpdateUserDto dto) @@ -544,6 +545,21 @@ 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")); + // Disallowed editing users synced via OIDC + var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + if (user.Owner == AppUserOwner.OpenIdConnect && + dto.Owner != AppUserOwner.Native && + oidcSettings.SyncUserSettings) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed")); + } + + var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + if (user.Id != defaultAdminUser.Id) + { + user.Owner = dto.Owner; + } + // Check if username is changing if (!user.UserName!.Equals(dto.Username)) { diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index 0cb0eaf66..232e159e5 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 AppUserOwner Owner { get; init; } } diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index f5f24b284..aaeb12a71 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 AppUserOwner Owner { get; init; } } diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs index adb15b200..81b153cad 100644 --- a/API/DTOs/Settings/OidcConfigDto.cs +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -15,11 +15,7 @@ public record OidcConfigDto: OidcPublicConfigDto /// /// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in. /// - public bool ProvisionUserSettings { get; set; } - /// - /// Requires roles to be configured in OIDC - /// - public bool RequireRoles { get; set; } = true; + public bool SyncUserSettings { get; set; } /// /// Returns true if the has been set diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 88dc97a5d..1379a11c0 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,6 +1,7 @@  -using System; using API.DTOs.Account; +using API.Entities; +using API.Entities.Enums; namespace API.DTOs; #nullable enable @@ -15,4 +16,6 @@ public sealed record UserDto public UserPreferencesDto? Preferences { get; set; } public AgeRestrictionDto? AgeRestriction { get; init; } public string KavitaVersion { get; set; } + /// + public AppUserOwner Owner { get; init; } } diff --git a/API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs b/API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs new file mode 100644 index 000000000..809f661c3 --- /dev/null +++ b/API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs @@ -0,0 +1,3728 @@ +// +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("20250701154425_AppUserOwner")] + partial class AppUserOwner + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + 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("ExternalId") + .HasColumnType("TEXT"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + 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("Owner") + .HasColumnType("INTEGER"); + + 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("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("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/20250701154425_AppUserOwner.cs b/API/Data/Migrations/20250701154425_AppUserOwner.cs new file mode 100644 index 000000000..6dfc6e7e5 --- /dev/null +++ b/API/Data/Migrations/20250701154425_AppUserOwner.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AppUserOwner : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExternalId", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Owner", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExternalId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "Owner", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 9acf91b4f..5059faa9c 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -119,6 +119,9 @@ namespace API.Data.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("Owner") + .HasColumnType("INTEGER"); + b.Property("PasswordHash") .HasColumnType("TEXT"); @@ -3637,7 +3640,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 8c954bf13..2e8488170 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -800,6 +800,7 @@ public class UserRepository : IUserRepository LastActiveUtc = u.LastActiveUtc, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), IsPending = !u.EmailConfirmed, + Owner = u.Owner, AgeRestriction = new AgeRestrictionDto() { AgeRating = u.AgeRestriction, @@ -811,7 +812,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/Entities/AppUser.cs b/API/Entities/AppUser.cs index d19418f1a..fd37126ab 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; @@ -93,6 +95,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// The sub returned the by OIDC provider /// public string? ExternalId { get; set; } + /// + /// Describes how the account was created + /// + public AppUserOwner Owner { get; set; } = AppUserOwner.Native; /// diff --git a/API/Entities/Enums/AppUserOwner.cs b/API/Entities/Enums/AppUserOwner.cs new file mode 100644 index 000000000..7f586ff4f --- /dev/null +++ b/API/Entities/Enums/AppUserOwner.cs @@ -0,0 +1,16 @@ +namespace API.Entities.Enums; + +/// +/// Who own the user, can be updated in the UI if desired +/// +public enum AppUserOwner +{ + /** + * Kavita has full control over the user + */ + Native = 0, + /** + * The user is synced with the OIDC provider + */ + OpenIdConnect = 1, +} diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 08644b8dc..e5f03430c 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Entities; +using API.Entities.Enums; using API.Helpers; using API.Services; using Kavita.Common; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext; using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext; @@ -158,9 +160,10 @@ public static class IdentityServiceExtensions private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx) { - var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); if (ctx.Principal == null) return; + var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); + var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService(); var user = await oidcService.LoginOrCreate(ctx.Principal); if (user == null) { @@ -169,17 +172,25 @@ public static class IdentityServiceExtensions return; } + // Add the following claims like Kavita expects them var claims = new List { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty), - new(ClaimTypes.Name, user.UserName ?? string.Empty) + new(ClaimTypes.Name, user.UserName ?? string.Empty), }; - var userManager = ctx.HttpContext.RequestServices.GetRequiredService>(); - var roles = await userManager.GetRolesAsync(user); - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); - claims.AddRange(ctx.Principal.Claims); + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings) + { + var userManager = ctx.HttpContext.RequestServices.GetRequiredService>(); + var roles = await userManager.GetRolesAsync(user); + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + } + else + { + claims.AddRange(ctx.Principal.Claims); + } var identity = new ClaimsIdentity(claims, ctx.Scheme.Name); var principal = new ClaimsPrincipal(identity); diff --git a/API/I18N/en.json b/API/I18N/en.json index b50c09051..d1e8346ac 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -18,6 +18,7 @@ "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": "This user is managed by OIDC, cannot edit", "username-taken": "Username already taken", "email-taken": "Email already in use", "user-already-confirmed": "User is already confirmed", diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 475c854e4..5692621ed 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -28,7 +28,7 @@ public interface IOidcService /// if any requirements aren't met Task LoginOrCreate(ClaimsPrincipal principal); /// - /// Updates roles, library access and age rating. Does not assign admin role, or to admin roles + /// Updates roles, library access and age rating. Will not modify the default admin /// /// /// @@ -68,6 +68,9 @@ public class OidcService(ILogger logger, UserManager userM if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail()) throw new KavitaException("errors.oidc.email-not-verified"); + if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0) + throw new KavitaException("errors.oidc.role-not-assigned"); + user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams) ?? await NewUserFromOpenIdConnect(settings, principal); @@ -126,6 +129,7 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.creating-user"); } + user.Owner = AppUserOwner.OpenIdConnect; AddDefaultStreamsToUser(user, mapper); await AddDefaultReadingProfileToUser(user); @@ -137,6 +141,7 @@ public class OidcService(ILogger logger, UserManager userM } await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole); + await userManager.AddToRoleAsync(user, PolicyConstants.PlebRole); await unitOfWork.CommitAsync(); return user; @@ -144,11 +149,10 @@ public class OidcService(ILogger logger, UserManager userM public async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) { - if (!settings.ProvisionUserSettings) return; - - var userRoles = await userManager.GetRolesAsync(user); - if (userRoles.Contains(PolicyConstants.AdminRole)) return; + if (!settings.SyncUserSettings) return; + var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); + if (defaultAdminUser.Id == user.Id) return; await SyncRoles(claimsPrincipal, user); await SyncLibraries(claimsPrincipal, user); @@ -161,29 +165,24 @@ public class OidcService(ILogger logger, UserManager userM private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user) { - var roles = claimsPrincipal.FindAll(ClaimTypes.Role) - .Select(r => r.Value) - .Where(r => PolicyConstants.ValidRoles.Contains(r)) - .Where(r => r != PolicyConstants.AdminRole) - .ToList(); - if (roles.Count == 0) return; - + var roles = claimsPrincipal.GetAccessRoles(); var errors = await accountService.UpdateRolesForUser(user, roles); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); } private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) { + var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + var libraryAccess = claimsPrincipal .FindAll(ClaimTypes.Role) .Where(r => r.Value.StartsWith(LibraryAccessPrefix)) .Select(r => r.Value.TrimPrefix(LibraryAccessPrefix)) .ToList(); - if (libraryAccess.Count == 0) return; + if (libraryAccess.Count == 0 && !hasAdminRole) return; var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList(); - var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); } diff --git a/UI/Web/src/app/_models/auth/member.ts b/UI/Web/src/app/_models/auth/member.ts index aaa45f332..ef0296f5d 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 {UserOwner} from "../user"; export interface Member { id: number; @@ -13,4 +14,5 @@ export interface Member { libraries: Library[]; ageRestriction: AgeRestriction; isPending: boolean; + owner: UserOwner; } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index a93272a37..5a8cb70b0 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -16,4 +16,12 @@ export interface User { ageRestriction: AgeRestriction; hasRunScrobbleEventGeneration: boolean; scrobbleEventGenerationRan: string; // datetime + owner: UserOwner, } + +export enum UserOwner { + Native = 0, + OpenIdConnect = 1, +} + +export const UserOwners: UserOwner[] = [UserOwner.Native, UserOwner.OpenIdConnect]; diff --git a/UI/Web/src/app/_pipes/user-owner.pipe.ts b/UI/Web/src/app/_pipes/user-owner.pipe.ts new file mode 100644 index 000000000..b18f9d205 --- /dev/null +++ b/UI/Web/src/app/_pipes/user-owner.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {UserOwner} from "../_models/user"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'creationSourcePipe' +}) +export class UserOwnerPipe implements PipeTransform { + + transform(value: UserOwner, ...args: unknown[]): string { + switch (value) { + case UserOwner.Native: + return translate("creation-source-pipe.native"); + case UserOwner.OpenIdConnect: + return translate("creation-source-pipe.oidc"); + } + } + +} diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index 8b8ca5d1d..2c56066bd 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -4,7 +4,7 @@ export interface OidcConfig { clientId: string; provisionAccounts: boolean; requireVerifiedEmail: boolean; - provisionUserSettings: boolean; + syncUserSettings: boolean; autoLogin: boolean; disablePasswordAuthentication: boolean; providerName: 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..b6e975acb 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,13 +1,26 @@ - +
- +
- +
- +
@@ -83,7 +116,7 @@ -
- @if(settingsForm.get('provisionUserSettings'); as formControl) { - + @if(settingsForm.get('syncUserSettings'); as formControl) { +
- +
diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index 214722944..d417dba54 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -51,7 +51,7 @@ export class ManageOpenIDConnectComponent implements OnInit { this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')])); this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, [])); this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); - this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, [])); + this.settingsForm.addControl('syncUserSettings', new FormControl(this.oidcSettings.syncUserSettings, [])); this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, [])); this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, [])); this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, [])); diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 72c345edd..43e651e7d 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -10,6 +10,7 @@ + @@ -20,6 +21,18 @@ @for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) { +
{{t('name-header')}} {{t('last-active-header')}} {{t('sharing-header')}}
+
+ @switch (member.owner) { + @case (UserOwner.OpenIdConnect) { + open-id-connect-logo + } + @case (UserOwner.Native) { + kavita-logo + } + } +
+
{{member.username | titlecase}} @if (member.isPending) { diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 6b62d6dc1..25a38763d 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -12,7 +12,7 @@ import {InviteUserComponent} from '../invite-user/invite-user.component'; import {EditUserComponent} from '../edit-user/edit-user.component'; import {Router} from '@angular/router'; import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component'; -import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common'; +import {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common'; import {TranslocoModule, TranslocoService} from "@jsverse/transloco"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; @@ -23,6 +23,9 @@ import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; import {DefaultModalOptions} from "../../_models/default-modal-options"; import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe"; import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe"; +import {SettingsService} from "../settings.service"; +import {ServerSettings} from "../_models/server-settings"; +import {UserOwner} from "../../_models/user"; @Component({ selector: 'app-manage-users', @@ -31,7 +34,7 @@ import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe"; changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe, - RoleLocalizedPipe] + RoleLocalizedPipe, NgOptimizedImage] }) export class ManageUsersComponent implements OnInit { @@ -41,6 +44,7 @@ export class ManageUsersComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); private readonly memberService = inject(MemberService); private readonly accountService = inject(AccountService); + private readonly settingsService = inject(SettingsService); private readonly modalService = inject(NgbModal); private readonly toastr = inject(ToastrService); private readonly confirmService = inject(ConfirmService); @@ -48,6 +52,7 @@ export class ManageUsersComponent implements OnInit { private readonly router = inject(Router); members: Member[] = []; + settings: ServerSettings | undefined = undefined; loggedInUsername = ''; loadingMembers = false; libraryCount: number = 0; @@ -64,6 +69,10 @@ export class ManageUsersComponent implements OnInit { ngOnInit(): void { this.loadMembers(); + + this.settingsService.getServerSettings().subscribe(settings => { + this.settings = settings; + }); } @@ -97,8 +106,11 @@ export class ManageUsersComponent implements OnInit { } openEditUser(member: Member) { + if (!this.settings) return; + const modalRef = this.modalService.open(EditUserComponent, DefaultModalOptions); - modalRef.componentInstance.member = member; + modalRef.componentInstance.member.set(member); + modalRef.componentInstance.settings.set(this.settings); modalRef.closed.subscribe(() => { this.loadMembers(); }); @@ -154,4 +166,6 @@ export class ManageUsersComponent implements OnInit { getRoles(member: Member) { return member.roles.filter(item => item != 'Pleb'); } + + protected readonly UserOwner = UserOwner; } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 8e3f715d5..f9a347480 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -31,8 +31,8 @@ "provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account", "requireVerifiedEmail": "Require verified emails", "requireVerifiedEmail-tooltip": "Requires emails to be verified when creation an account or matching with existing ones. A newly created account with a verified email, will be auto verified on Kavita's side", - "provisionUserSettings": "Provision user settings", - "provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information", + "syncUserSettings": "Sync user settings with OIDC roles", + "syncUserSettings-tooltip": "Users created from OIDC will be fully managed (Roles, Library Access, Age Rating) by the OIDC. If this is disabled, users will be unable to access any content after their account creation. Read the documentation for more information.", "autoLogin": "Auto login", "autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen", "disablePasswordAuthentication": "Disable password authentication", @@ -42,6 +42,11 @@ } }, + "creation-source-pipe": { + "native": "Native", + "oidc": "OpenID Connect" + }, + "dashboard": { "no-libraries": "There are no libraries setup yet. Create some in", "server-settings-link": "Server settings", @@ -62,7 +67,12 @@ "cancel": "{{common.cancel}}", "saving": "Saving…", "update": "Update", - "account-detail-title": "Account Details" + "account-detail-title": "Account Details", + "notice": "Warning!", + "out-of-sync": "This user was created via OIDC, if the SynUsers setting is turned on changes made may be lost", + "oidc-managed": "This user is managed via OIDC, contact your OIDC administrator if they require changes.", + "creationSource": "User type", + "creationSource-tooltip": "Native users will never be synced with OIDC" }, "user-scrobble-history": { @@ -2461,7 +2471,8 @@ "email-not-verified": "Your email must be verified to allow logging in via OpenID Connect", "no-account": "No matching account found", "disabled-account": "This account is disabled, please contact an administrator", - "creating-user": "Failed to create a new user, please contact an administrator" + "creating-user": "Failed to create a new user, please contact an administrator", + "role-not-assigned": "You do not have the required roles assigned to access this application" } }, From a122ae07a9bfde8ab42752bbe15b60bd5dce7994 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:02:37 +0200 Subject: [PATCH 14/27] Add default values for when Sync is off --- API/DTOs/Settings/OidcConfigDto.cs | 14 ++++ API/Services/OidcService.cs | 63 ++++++++++++----- UI/Web/src/app/_pipes/age-rating.pipe.ts | 6 +- UI/Web/src/app/_pipes/user-owner.pipe.ts | 2 +- UI/Web/src/app/admin/_models/oidc-config.ts | 5 ++ .../admin/edit-user/edit-user.component.html | 4 +- .../admin/edit-user/edit-user.component.ts | 2 +- .../library-selector.component.ts | 11 ++- .../manage-open-idconnect.component.html | 49 +++++++++++++ .../manage-open-idconnect.component.ts | 69 +++++++++++++++---- .../role-selector/role-selector.component.ts | 10 ++- UI/Web/src/assets/langs/en.json | 10 ++- 12 files changed, 203 insertions(+), 42 deletions(-) diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs index 81b153cad..6b4beca76 100644 --- a/API/DTOs/Settings/OidcConfigDto.cs +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -1,5 +1,8 @@ #nullable enable +using System.Collections.Generic; +using API.Entities.Enums; + namespace API.DTOs.Settings; public record OidcConfigDto: OidcPublicConfigDto @@ -17,6 +20,17 @@ public record OidcConfigDto: OidcPublicConfigDto /// public bool SyncUserSettings { 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 DefaultAgeRating { get; set; } = AgeRating.Unknown; + public bool DefaultIncludeUnknowns { get; set; } = false; + + #endregion + + /// /// Returns true if the has been set /// diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 5692621ed..aefde66a1 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; @@ -56,10 +57,7 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.missing-external-id"); var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); - if (user != null) - { - return user; - } + if (user != null) return user; var email = principal.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrEmpty(email)) @@ -68,18 +66,23 @@ public class OidcService(ILogger logger, UserManager userM 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) + { + logger.LogInformation("User {Name} has matched on email to {ExternalId}", user.UserName, externalId); + user.ExternalId = externalId; + await unitOfWork.CommitAsync(); + return user; + } + + // Cannot match on native account, try and create new one if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0) throw new KavitaException("errors.oidc.role-not-assigned"); - - user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams) - ?? await NewUserFromOpenIdConnect(settings, principal); + user = await NewUserFromOpenIdConnect(settings, principal, externalId); if (user == null) return null; - user.ExternalId = externalId; - - await SyncUserSettings(settings, principal, user); - var roles = await userManager.GetRolesAsync(user); if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) throw new KavitaException("errors.oidc.disabled-account"); @@ -98,14 +101,15 @@ public class OidcService(ILogger logger, UserManager userM await unitOfWork.CommitAsync(); } - private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal) + private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId) { if (!settings.ProvisionAccounts) return null; var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null; - var name = claimsPrincipal.FindFirstValue(ClaimTypes.Name); + var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername); + name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Name); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname); name ??= emailClaim.Value; @@ -117,6 +121,8 @@ public class OidcService(ILogger logger, UserManager userM name = emailClaim.Value; } + logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId); + // TODO: Move to account service, as we're sharing code with AccountController var user = new AppUserBuilder(name, emailClaim.Value, await unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build(); @@ -129,10 +135,6 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.creating-user"); } - user.Owner = AppUserOwner.OpenIdConnect; - AddDefaultStreamsToUser(user, mapper); - await AddDefaultReadingProfileToUser(user); - if (settings.RequireVerifiedEmail) { // Email has been verified by OpenID Connect provider @@ -140,13 +142,36 @@ public class OidcService(ILogger logger, UserManager userM await userManager.ConfirmEmailAsync(user, token); } - await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole); - await userManager.AddToRoleAsync(user, PolicyConstants.PlebRole); + user.ExternalId = externalId; + user.Owner = AppUserOwner.OpenIdConnect; + + AddDefaultStreamsToUser(user, mapper); + await AddDefaultReadingProfileToUser(user); + await SyncUserSettings(settings, claimsPrincipal, user); + await SetDefaults(settings, user); await unitOfWork.CommitAsync(); return user; } + private async Task SetDefaults(OidcConfigDto settings, AppUser user) + { + if (settings.SyncUserSettings) return; + + // 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.DefaultAgeRating; + user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns; + + await unitOfWork.CommitAsync(); + } + public async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) { if (!settings.SyncUserSettings) return; 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/user-owner.pipe.ts b/UI/Web/src/app/_pipes/user-owner.pipe.ts index b18f9d205..323bdbed1 100644 --- a/UI/Web/src/app/_pipes/user-owner.pipe.ts +++ b/UI/Web/src/app/_pipes/user-owner.pipe.ts @@ -3,7 +3,7 @@ import {UserOwner} from "../_models/user"; import {translate} from "@jsverse/transloco"; @Pipe({ - name: 'creationSourcePipe' + name: 'userOwnerPipe' }) export class UserOwnerPipe implements PipeTransform { diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index 2c56066bd..d48cdf088 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -1,3 +1,4 @@ +import {AgeRating} from "../../_models/metadata/age-rating"; export interface OidcConfig { authority: string; @@ -8,6 +9,10 @@ export interface OidcConfig { autoLogin: boolean; disablePasswordAuthentication: boolean; providerName: string; + defaultRoles: string[]; + defaultLibraries: number[]; + defaultAgeRating: AgeRating; + defaultIncludeUnknowns: boolean; } export interface OidcPublicConfig { 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 b6e975acb..66cae449b 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 @@ -78,12 +78,12 @@ @if (userForm.get('owner'); as formControl) { -
{{member().owner | UserOwnerPipe}}
+
{{member().owner | userOwnerPipe}}
diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index 7ae919bad..c36bfc135 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -77,7 +77,7 @@ export class EditUserComponent implements OnInit { ngOnInit(): void { this.userForm.addControl('email', new FormControl(this.member().email, [Validators.required])); this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); - this.userForm.addControl('creationSource', new FormControl(this.member().owner, [Validators.required])); + this.userForm.addControl('owner', new FormControl(this.member().owner, [Validators.required])); // TODO: Rework, bad hack // Work around isLocked so we're able to downgrade users diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.ts b/UI/Web/src/app/admin/library-selector/library-selector.component.ts index d562f9601..7445ed46c 100644 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.ts +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectorRef, Component, EventEmitter, - inject, + inject, input, Input, OnInit, Output @@ -29,6 +29,7 @@ export class LibrarySelectorComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); @Input() member: Member | undefined; + preselectedLibraries = input([]); @Output() selected: EventEmitter> = new EventEmitter>(); allLibraries: Library[] = []; @@ -61,6 +62,14 @@ export class LibrarySelectorComponent implements OnInit { }); this.selectAll = this.selections.selected().length === this.allLibraries.length; this.selected.emit(this.selections.selected()); + } else if (this.preselectedLibraries().length > 0) { + this.preselectedLibraries().forEach((id) => { + const foundLib = this.allLibraries.find(lib => lib.id === id); + if (foundLib) { + this.selections.toggle(foundLib, true, (a, b) => a.name === b.name); + } + }); + this.selectAll = this.selections.selected().length === this.allLibraries.length; } this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index 4623d4b5d..a3344bcb4 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -141,6 +141,55 @@ +
+

{{t('defaults')}}

+
{{t('defaults-requirement')}}
+ + +
+ @if(settingsForm.get('defaultAgeRating'); as formControl) { + + +
{{formControl.value | ageRating}}
+
+ + + +
+ } +
+ +
+ @if(settingsForm.get('defaultIncludeUnknowns'); as formControl) { + + +
+ +
+
+
+ } +
+ + @if (this.oidcSettings()) { +
+
+ +
+ +
+ +
+
+ } + +
+ diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index d417dba54..c2419443c 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectorRef, Component, DestroyRef, OnInit} from '@angular/core'; +import {ChangeDetectorRef, Component, DestroyRef, effect, OnInit, signal} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {ServerSettings} from "../_models/server-settings"; import { @@ -16,6 +16,16 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {RestrictionSelectorComponent} from "../../user-settings/restriction-selector/restriction-selector.component"; +import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; +import {MetadataService} from "../../_services/metadata.service"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {allRoles, Role} from "../../_services/account.service"; +import {Library} from "../../_models/library/library"; +import {LibraryService} from "../../_services/library.service"; +import {LibrarySelectorComponent} from "../library-selector/library-selector.component"; +import {RoleSelectorComponent} from "../role-selector/role-selector.component"; @Component({ selector: 'app-manage-open-idconnect', @@ -23,7 +33,10 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; TranslocoDirective, ReactiveFormsModule, SettingItemComponent, - SettingSwitchComponent + SettingSwitchComponent, + AgeRatingPipe, + LibrarySelectorComponent, + RoleSelectorComponent ], templateUrl: './manage-open-idconnect.component.html', styleUrl: './manage-open-idconnect.component.scss' @@ -31,30 +44,43 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; export class ManageOpenIDConnectComponent implements OnInit { serverSettings!: ServerSettings; - oidcSettings!: OidcConfig; + oidcSettings = signal(undefined); settingsForm: FormGroup = new FormGroup({}); + ageRatings = signal([]); + selectedLibraries = signal([]); + selectedRoles = signal([]); + constructor( private settingsService: SettingsService, private cdRef: ChangeDetectorRef, private destroyRef: DestroyRef, + private metadataService: MetadataService, ) { } ngOnInit(): void { + this.metadataService.getAllAgeRatings().subscribe(ratings => { + this.ageRatings.set(ratings); + }); + this.settingsService.getServerSettings().subscribe({ next: data => { this.serverSettings = data; - this.oidcSettings = this.serverSettings.oidcConfig; + this.oidcSettings.set(this.serverSettings.oidcConfig); + this.selectedRoles.set(this.serverSettings.oidcConfig.defaultRoles); + this.selectedLibraries.set(this.serverSettings.oidcConfig.defaultLibraries); - this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, [], [this.authorityValidator()])); - this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')])); - this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, [])); - this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); - this.settingsForm.addControl('syncUserSettings', new FormControl(this.oidcSettings.syncUserSettings, [])); - this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, [])); - this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, [])); - this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, [])); + this.settingsForm.addControl('authority', new FormControl(this.serverSettings.oidcConfig.authority, [], [this.authorityValidator()])); + this.settingsForm.addControl('clientId', new FormControl(this.serverSettings.oidcConfig.clientId, [this.requiredIf('authority')])); + this.settingsForm.addControl('provisionAccounts', new FormControl(this.serverSettings.oidcConfig.provisionAccounts, [])); + this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.serverSettings.oidcConfig.requireVerifiedEmail, [])); + this.settingsForm.addControl('syncUserSettings', new FormControl(this.serverSettings.oidcConfig.syncUserSettings, [])); + this.settingsForm.addControl('autoLogin', new FormControl(this.serverSettings.oidcConfig.autoLogin, [])); + this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.serverSettings.oidcConfig.disablePasswordAuthentication, [])); + this.settingsForm.addControl('providerName', new FormControl(this.serverSettings.oidcConfig.providerName, [])); + this.settingsForm.addControl("defaultAgeRating", new FormControl(this.serverSettings.oidcConfig.defaultAgeRating, [])); + this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, [])); this.cdRef.markForCheck(); this.settingsForm.valueChanges.pipe( @@ -64,7 +90,7 @@ export class ManageOpenIDConnectComponent implements OnInit { filter(() => { // Do not auto save when provider settings have changed const settings: OidcConfig = this.settingsForm.getRawValue(); - return settings.authority == this.oidcSettings.authority && settings.clientId == this.oidcSettings.clientId; + return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId; }), tap(() => this.save()) ).subscribe(); @@ -72,17 +98,30 @@ export class ManageOpenIDConnectComponent implements OnInit { }); } + updateRoles(roles: string[]) { + this.selectedRoles.set(roles); + this.save(); + } + + updateLibraries(libraries: Library[]) { + this.selectedLibraries.set(libraries.map(l => l.id)); + this.save(); + } + save() { - if (!this.settingsForm.valid) return; + if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings) return; const data = this.settingsForm.getRawValue(); const newSettings = Object.assign({}, this.serverSettings); newSettings.oidcConfig = data as OidcConfig; + newSettings.oidcConfig.defaultAgeRating = parseInt(newSettings.oidcConfig.defaultAgeRating as unknown as string, 10) as AgeRating; + newSettings.oidcConfig.defaultRoles = this.selectedRoles(); + newSettings.oidcConfig.defaultLibraries = this.selectedLibraries(); this.settingsService.updateServerSettings(newSettings).subscribe({ next: data => { this.serverSettings = data; - this.oidcSettings = data.oidcConfig; + this.oidcSettings.set(data.oidcConfig); this.cdRef.markForCheck(); }, error: error => { diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.ts b/UI/Web/src/app/admin/role-selector/role-selector.component.ts index d793e55b3..ab9bbb6f5 100644 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.ts +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectorRef, Component, EventEmitter, - inject, + inject, input, Input, OnInit, Output @@ -33,6 +33,7 @@ export class RoleSelectorComponent implements OnInit { * This must have roles */ @Input() member: Member | undefined | User; + preSelectedRoles = input([]); /** * Allows the selection of Admin role */ @@ -77,6 +78,13 @@ export class RoleSelectorComponent implements OnInit { foundRole[0].selected = true; } }); + } else if (this.preSelectedRoles().length > 0) { + this.preSelectedRoles().forEach((role) => { + const foundRole = this.selectedRoles.filter(item => item.data === role); + if (foundRole.length > 0) { + foundRole[0].selected = true; + } + }); } else { // For new users, preselect LoginRole this.selectedRoles.forEach(role => { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index f9a347480..8536415fb 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -38,7 +38,15 @@ "disablePasswordAuthentication": "Disable password authentication", "disablePasswordAuthentication-tooltip": "Users with the admin role can bypass this restriction", "providerName": "Provider name", - "providerName-tooltip": "Name show on the login screen" + "providerName-tooltip": "Name show on the login screen", + + "defaults": "Defaults", + "defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off", + "defaultIncludeUnknowns": "Include unknowns", + "defaultIncludeUnknowns-tooltip": "Include unknown age ratings", + "defaultAgeRating": "Age rating", + "defaultAgeRating-tooltip": "Maximum age rating shown to new users", + "no-restriction": "{{restriction-selector.no-restriction}}" } }, From 08914f7546050516a76491e77453b0f45b475478 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:22:17 +0200 Subject: [PATCH 15/27] Fix the login flashing sometimes Race condition between being able to login via OIDC in the background, and making first requests --- UI/Web/src/app/_resolvers/oidc.resolver.ts | 16 ++++++++++++++++ UI/Web/src/app/_services/oidc.service.ts | 10 ++++++---- UI/Web/src/app/app-routing.module.ts | 7 +++++++ 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 UI/Web/src/app/_resolvers/oidc.resolver.ts diff --git a/UI/Web/src/app/_resolvers/oidc.resolver.ts b/UI/Web/src/app/_resolvers/oidc.resolver.ts new file mode 100644 index 000000000..9124cddec --- /dev/null +++ b/UI/Web/src/app/_resolvers/oidc.resolver.ts @@ -0,0 +1,16 @@ +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {Injectable} from "@angular/core"; +import {Observable, take} from "rxjs"; +import {OidcService} from "../_services/oidc.service"; + +@Injectable({ + providedIn: 'root' +}) +export class OidcResolver implements Resolve { + + constructor(private oidcService: OidcService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.oidcService.loaded$.pipe(take(1)); + } +} diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 1efaeb348..e66c93cc2 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -1,6 +1,6 @@ import {DestroyRef, effect, inject, Injectable, signal} from '@angular/core'; import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc"; -import {BehaviorSubject, from} from "rxjs"; +import {BehaviorSubject, from, Observable} from "rxjs"; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {OidcPublicConfig} from "../admin/_models/oidc-config"; @@ -23,6 +23,8 @@ export class OidcService { baseUrl = environment.apiUrl; + private readonly loaded = new BehaviorSubject(false); + public readonly loaded$: Observable = this.loaded.asObservable(); private readonly _ready = signal(false); public readonly ready = this._ready.asReadonly(); private readonly _settings = signal(undefined); @@ -42,6 +44,7 @@ export class OidcService { this.config().subscribe(oidcSetting => { if (!oidcSetting.authority) { + this.loaded.next(true); return } @@ -72,9 +75,8 @@ export class OidcService { }); from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ - next: success => { - if (!success) return; - + next: _ => { + this.loaded.next(true); this._ready.set(true); }, error: error => { diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index e03c924a1..bdbe6e831 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -2,12 +2,19 @@ import {NgModule} from '@angular/core'; import {PreloadAllModules, RouterModule, Routes} from '@angular/router'; import {AuthGuard} from './_guards/auth.guard'; import {LibraryAccessGuard} from './_guards/library-access.guard'; +import {OidcResolver} from "./_resolvers/oidc.resolver"; const routes: Routes = [ { path: '', canActivate: [AuthGuard], runGuardsAndResolvers: 'always', + resolve: { + // Require OIDC discovery to be loaded before launching the app + // making sure we don't flash the login screen because we've made request before we could auto login + // If no OIDC is set up, this will resolve after one request to the backend + _: OidcResolver, + }, children: [ { path: 'settings', From 9979220641194a54f354b0421b61c498e84be23c Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:41:19 +0200 Subject: [PATCH 16/27] Bits of cleanup & support custom base urls --- UI/Web/src/app/_resolvers/oidc.resolver.ts | 20 ++++++-- UI/Web/src/app/_services/oidc.service.ts | 57 ++++++++++++++-------- UI/Web/src/assets/langs/en.json | 1 + 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/UI/Web/src/app/_resolvers/oidc.resolver.ts b/UI/Web/src/app/_resolvers/oidc.resolver.ts index 9124cddec..bedc69d27 100644 --- a/UI/Web/src/app/_resolvers/oidc.resolver.ts +++ b/UI/Web/src/app/_resolvers/oidc.resolver.ts @@ -1,16 +1,26 @@ -import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; -import {Injectable} from "@angular/core"; -import {Observable, take} from "rxjs"; +import {ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot} from '@angular/router'; +import {inject, Injectable} from "@angular/core"; +import {catchError, filter, Observable, of, take, timeout} from "rxjs"; import {OidcService} from "../_services/oidc.service"; +import {ToastrService} from "ngx-toastr"; @Injectable({ providedIn: 'root' }) export class OidcResolver implements Resolve { - constructor(private oidcService: OidcService) {} + private oidcService = inject(OidcService); + private toastR = inject(ToastrService); resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.oidcService.loaded$.pipe(take(1)); + return this.oidcService.loaded$.pipe( + filter(value => value), + take(1), + timeout(5000), + catchError(err => { + console.log(err); + this.toastR.error("oidc.timeout"); + return of(true); + })); } } diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index e66c93cc2..4710e5cbe 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -5,10 +5,11 @@ import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {OidcPublicConfig} from "../admin/_models/oidc-config"; import {AccountService} from "./account.service"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop"; import {take} from "rxjs/operators"; import {ToastrService} from "ngx-toastr"; import {translate} from "@jsverse/transloco"; +import {APP_BASE_HREF} from "@angular/common"; @Injectable({ providedIn: 'root' @@ -21,19 +22,32 @@ export class OidcService { private readonly destroyRef = inject(DestroyRef); private readonly toastR = inject(ToastrService); - baseUrl = environment.apiUrl; + protected readonly baseUrl = inject(APP_BASE_HREF); + apiBaseUrl = environment.apiUrl; - private readonly loaded = new BehaviorSubject(false); - public readonly loaded$: Observable = this.loaded.asObservable(); + /** + * True when the OIDC discovery document has been loaded, and login tried. Or no OIDC has been set up + */ + private readonly _loaded = signal(false); + public readonly loaded = this._loaded.asReadonly(); + public readonly loaded$ = toObservable(this.loaded); + + /** + * OIDC discovery document has been loaded, and login tried and OIDC has been set up + */ private readonly _ready = signal(false); public readonly ready = this._ready.asReadonly(); + + /** + * Public OIDC settings + */ private readonly _settings = signal(undefined); public readonly settings = this._settings.asReadonly(); constructor() { // log events in dev if (!environment.production) { - this.oauth2.events.subscribe(event => { + this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { if (event instanceof OAuthErrorEvent) { console.error('OAuthErrorEvent Object:', event); } else { @@ -42,9 +56,20 @@ export class OidcService { }); } + this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { + if (event.type !== "token_refreshed" && event.type != 'token_received') return; + + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (!user) return; // Don't update tokens when we're not logged in. But what's going on? + + // TODO: Do we need to refresh the SignalR connection here? + user.oidcToken = this.token; + }); + }); + this.config().subscribe(oidcSetting => { if (!oidcSetting.authority) { - this.loaded.next(true); + this._loaded.set(true); return } @@ -53,30 +78,20 @@ export class OidcService { clientId: oidcSetting.clientId, // Require https in production unless localhost requireHttps: environment.production ? 'remoteOnly' : false, - redirectUri: window.location.origin + "/oidc/callback", - postLogoutRedirectUri: window.location.origin + "/login", + redirectUri: window.location.origin + this.baseUrl + "oidc/callback", + postLogoutRedirectUri: window.location.origin + this.baseUrl + "login", showDebugInformation: !environment.production, responseType: 'code', scope: "openid profile email roles offline_access", + // Not all OIDC providers follow this nicely strictDiscoveryDocumentValidation: false, }); this._settings.set(oidcSetting); this.oauth2.setupAutomaticSilentRefresh(); - this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { - if (event.type !== "token_refreshed") return; - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (!user) return; // Don't update tokens when we're not logged in. But what's going on? - - // TODO: Do we need to refresh the SignalR connection here? - user.oidcToken = this.token; - }); - }); - from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ next: _ => { - this.loaded.next(true); + this._loaded.set(true); this._ready.set(true); }, error: error => { @@ -99,7 +114,7 @@ export class OidcService { } config() { - return this.httpClient.get(this.baseUrl + "oidc/config"); + return this.httpClient.get(this.apiBaseUrl + "oidc/config"); } get token() { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 8536415fb..f53a6e681 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -13,6 +13,7 @@ "title": "OpenID Connect Callback", "login": "Back to login screen", "error-loading-info": "An error occurred loading OpenID Connect info, contact your administrator", + "timeout": "OIDC resolution has timed out or an error has occurred", "settings": { "save": "{{common.save}}", "notice": "Notice", From f868f5df917d78c8f325ba4b72f2a1ea498df30d Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 23:32:57 +0200 Subject: [PATCH 17/27] Cleanup edit user modal --- .../admin/edit-user/edit-user.component.html | 35 +++++++------------ .../admin/edit-user/edit-user.component.scss | 3 ++ .../admin/edit-user/edit-user.component.ts | 3 +- .../manage-users/manage-users.component.html | 9 +---- .../manage-users/manage-users.component.ts | 3 +- .../owner-icon/owner-icon.component.html | 9 +++++ .../owner-icon/owner-icon.component.scss | 0 .../owner-icon/owner-icon.component.ts | 19 ++++++++++ UI/Web/src/assets/langs/en.json | 4 +-- 9 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.html create mode 100644 UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.scss create mode 100644 UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.ts 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 66cae449b..d88fa50e2 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 @@ -24,7 +24,18 @@

{{t('account-detail-title')}}

-
+
+ @if (userForm.get('owner'); as formControl) { + + + {{t('owner-tooltip')}} + } +
+
@if(userForm.get('username'); as formControl) {
@@ -46,7 +57,7 @@
}
-
+
@if(userForm.get('email'); as formControl) {
@@ -72,26 +83,6 @@
}
- - -
- @if (userForm.get('owner'); as formControl) { - - -
{{member().owner | userOwnerPipe}}
-
- - - -
- } -
- -
diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.scss b/UI/Web/src/app/admin/edit-user/edit-user.component.scss index e69de29bb..3cca8a49f 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.scss +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.scss @@ -0,0 +1,3 @@ +.text-muted { + font-size: 12px; +} diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index c36bfc135..124810678 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -28,6 +28,7 @@ import {ServerSettings} from "../_models/server-settings"; import {UserOwner, UserOwners} from "../../_models/user"; import {UserOwnerPipe} from "../../_pipes/user-owner.pipe"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {OwnerIconComponent} from "../../shared/_components/owner-icon/owner-icon.component"; const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/; const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -36,7 +37,7 @@ const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; selector: 'app-edit-user', templateUrl: './edit-user.component.html', styleUrls: ['./edit-user.component.scss'], - imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, UserOwnerPipe, SettingItemComponent], + imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, UserOwnerPipe, SettingItemComponent, OwnerIconComponent], changeDetection: ChangeDetectionStrategy.OnPush }) export class EditUserComponent implements OnInit { diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 43e651e7d..2cd4b7707 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -23,14 +23,7 @@
- @switch (member.owner) { - @case (UserOwner.OpenIdConnect) { - open-id-connect-logo - } - @case (UserOwner.Native) { - kavita-logo - } - } +
diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 25a38763d..e38f04d2e 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -26,6 +26,7 @@ import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe"; import {SettingsService} from "../settings.service"; import {ServerSettings} from "../_models/server-settings"; import {UserOwner} from "../../_models/user"; +import {OwnerIconComponent} from "../../shared/_components/owner-icon/owner-icon.component"; @Component({ selector: 'app-manage-users', @@ -34,7 +35,7 @@ import {UserOwner} from "../../_models/user"; changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe, - RoleLocalizedPipe, NgOptimizedImage] + RoleLocalizedPipe, NgOptimizedImage, OwnerIconComponent] }) export class ManageUsersComponent implements OnInit { diff --git a/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.html b/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.html new file mode 100644 index 000000000..af93e0b4d --- /dev/null +++ b/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.html @@ -0,0 +1,9 @@ +@switch (owner()) { + @case (UserOwner.OpenIdConnect) { + open-id-connect-logo + } + @case (UserOwner.Native) { + + kavita-logo + } +} diff --git a/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.scss b/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.ts b/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.ts new file mode 100644 index 000000000..bc1fbceac --- /dev/null +++ b/UI/Web/src/app/shared/_components/owner-icon/owner-icon.component.ts @@ -0,0 +1,19 @@ +import {Component, input} from '@angular/core'; +import {NgOptimizedImage} from "@angular/common"; +import {UserOwner} from "../../../_models/user"; + +@Component({ + selector: 'app-owner-icon', + imports: [ + NgOptimizedImage + ], + templateUrl: './owner-icon.component.html', + styleUrl: './owner-icon.component.scss' +}) +export class OwnerIconComponent { + + owner = input.required(); + size = input(16); + + protected readonly UserOwner = UserOwner; +} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index f53a6e681..884b1f443 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -80,8 +80,8 @@ "notice": "Warning!", "out-of-sync": "This user was created via OIDC, if the SynUsers setting is turned on changes made may be lost", "oidc-managed": "This user is managed via OIDC, contact your OIDC administrator if they require changes.", - "creationSource": "User type", - "creationSource-tooltip": "Native users will never be synced with OIDC" + "owner": "User type", + "owner-tooltip": "Native users will never be synced with OIDC" }, "user-scrobble-history": { From 63a5750f280017709c7828a7b7064af630bfedf1 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:58:47 +0200 Subject: [PATCH 18/27] Only use OIDC token when it's valid Need to extend the wait logic to the JWT interceptor as well --- UI/Web/src/app/_interceptors/jwt.interceptor.ts | 6 ++++-- UI/Web/src/app/_models/user.ts | 3 --- UI/Web/src/app/_services/account.service.ts | 9 +++------ UI/Web/src/app/_services/message-hub.service.ts | 5 +++-- UI/Web/src/app/_services/oidc.service.ts | 16 ++++------------ 5 files changed, 14 insertions(+), 25 deletions(-) diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index 74f5243d3..4cc9cf5f1 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -3,20 +3,22 @@ import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/c import {Observable, switchMap} from 'rxjs'; import { AccountService } from '../_services/account.service'; import { take } from 'rxjs/operators'; +import { OidcService } from '../_services/oidc.service'; @Injectable() export class JwtInterceptor implements HttpInterceptor { - constructor(private accountService: AccountService) {} + constructor(private accountService: AccountService, private oidcService: OidcService) { } intercept(request: HttpRequest, next: HttpHandler): Observable> { return this.accountService.currentUser$.pipe( take(1), switchMap(user => { if (user) { + const token = this.oidcService.hasValidToken() ? this.oidcService.token : user.token; request = request.clone({ setHeaders: { - Authorization: `Bearer ${user.oidcToken ?? user.token}` + Authorization: `Bearer ${token}` } }); } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 5a8cb70b0..29bef2fbc 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -4,9 +4,6 @@ import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { username: string; - // This is set by the oidc service, will always take precedence over the Kavita generated token - // When set, the refresh logic for the Kavita token will not run - oidcToken: string; token: string; refreshToken: string; roles: string[]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 52a618fc5..f903f8ad0 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -17,6 +17,7 @@ import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; import {LicenseService} from "./license.service"; import {LocalizationService} from "./localization.service"; +import {OidcService} from "./oidc.service"; export enum Role { Admin = 'Admin', @@ -46,6 +47,7 @@ export const allRoles = [ export class AccountService { private readonly destroyRef = inject(DestroyRef); + private readonly oidcService = inject(OidcService); private readonly licenseService = inject(LicenseService); private readonly localizationService = inject(LocalizationService); @@ -92,10 +94,6 @@ export class AccountService { }); } - oidcEnabled() { - return this.httpClient.get(this.baseUrl + "oidc/enabled"); - } - canInvokeAction(user: User, action: Action) { const isAdmin = this.hasAdminRole(user); const canDownload = this.hasDownloadRole(user); @@ -219,7 +217,6 @@ export class AccountService { tap((response: User) => { const user = response; if (user) { - user.oidcToken = token; this.setCurrentUser(user); } }), @@ -263,7 +260,7 @@ export class AccountService { this.licenseService.hasValidLicense().subscribe(); } // oidc handles refreshing itself - if (!this.currentUser.oidcToken) { + if (!this.oidcService.hasValidToken()) { this.startRefreshTokenTimer(); } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index ab03fbb0a..daeae4c99 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -11,6 +11,7 @@ import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; +import {OidcService} from "./oidc.service"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -146,7 +147,7 @@ export class MessageHubService { */ public onlineUsers$ = this.onlineUsersSource.asObservable(); - constructor() {} + constructor(private oidcService: OidcService) {} /** * Tests that an event is of the type passed @@ -165,7 +166,7 @@ export class MessageHubService { createHubConnection(user: User) { this.hubConnection = new HubConnectionBuilder() .withUrl(this.hubUrl + 'messages', { - accessTokenFactory: () => user.oidcToken ?? user.token + accessTokenFactory: () => this.oidcService.hasValidToken() ? this.oidcService.token : user.token }) .withAutomaticReconnect() //.withStatefulReconnect() // Requires signalr@8.0 diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 4710e5cbe..04dfe3b4b 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -18,7 +18,6 @@ export class OidcService { private readonly oauth2 = inject(OAuthService); private readonly httpClient = inject(HttpClient); - private readonly accountService = inject(AccountService); private readonly destroyRef = inject(DestroyRef); private readonly toastR = inject(ToastrService); @@ -56,17 +55,6 @@ export class OidcService { }); } - this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { - if (event.type !== "token_refreshed" && event.type != 'token_received') return; - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (!user) return; // Don't update tokens when we're not logged in. But what's going on? - - // TODO: Do we need to refresh the SignalR connection here? - user.oidcToken = this.token; - }); - }); - this.config().subscribe(oidcSetting => { if (!oidcSetting.authority) { this._loaded.set(true); @@ -121,4 +109,8 @@ export class OidcService { return this.oauth2.getAccessToken(); } + hasValidToken() { + return this.oauth2.hasValidAccessToken(); + } + } From 626bb3b7191e915339df306175e168648543c7a2 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:18:24 +0200 Subject: [PATCH 19/27] Revert "Only use OIDC token when it's valid" This reverts commit 63a5750f280017709c7828a7b7064af630bfedf1. --- UI/Web/src/app/_interceptors/jwt.interceptor.ts | 6 ++---- UI/Web/src/app/_models/user.ts | 3 +++ UI/Web/src/app/_services/account.service.ts | 9 ++++++--- UI/Web/src/app/_services/message-hub.service.ts | 5 ++--- UI/Web/src/app/_services/oidc.service.ts | 16 ++++++++++++---- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index 4cc9cf5f1..74f5243d3 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -3,22 +3,20 @@ import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/c import {Observable, switchMap} from 'rxjs'; import { AccountService } from '../_services/account.service'; import { take } from 'rxjs/operators'; -import { OidcService } from '../_services/oidc.service'; @Injectable() export class JwtInterceptor implements HttpInterceptor { - constructor(private accountService: AccountService, private oidcService: OidcService) { } + constructor(private accountService: AccountService) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { return this.accountService.currentUser$.pipe( take(1), switchMap(user => { if (user) { - const token = this.oidcService.hasValidToken() ? this.oidcService.token : user.token; request = request.clone({ setHeaders: { - Authorization: `Bearer ${token}` + Authorization: `Bearer ${user.oidcToken ?? user.token}` } }); } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 29bef2fbc..5a8cb70b0 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -4,6 +4,9 @@ import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { username: string; + // This is set by the oidc service, will always take precedence over the Kavita generated token + // When set, the refresh logic for the Kavita token will not run + oidcToken: string; token: string; refreshToken: string; roles: string[]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index f903f8ad0..52a618fc5 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -17,7 +17,6 @@ import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; import {LicenseService} from "./license.service"; import {LocalizationService} from "./localization.service"; -import {OidcService} from "./oidc.service"; export enum Role { Admin = 'Admin', @@ -47,7 +46,6 @@ export const allRoles = [ export class AccountService { private readonly destroyRef = inject(DestroyRef); - private readonly oidcService = inject(OidcService); private readonly licenseService = inject(LicenseService); private readonly localizationService = inject(LocalizationService); @@ -94,6 +92,10 @@ export class AccountService { }); } + oidcEnabled() { + return this.httpClient.get(this.baseUrl + "oidc/enabled"); + } + canInvokeAction(user: User, action: Action) { const isAdmin = this.hasAdminRole(user); const canDownload = this.hasDownloadRole(user); @@ -217,6 +219,7 @@ export class AccountService { tap((response: User) => { const user = response; if (user) { + user.oidcToken = token; this.setCurrentUser(user); } }), @@ -260,7 +263,7 @@ export class AccountService { this.licenseService.hasValidLicense().subscribe(); } // oidc handles refreshing itself - if (!this.oidcService.hasValidToken()) { + if (!this.currentUser.oidcToken) { this.startRefreshTokenTimer(); } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index daeae4c99..ab03fbb0a 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -11,7 +11,6 @@ import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; -import {OidcService} from "./oidc.service"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -147,7 +146,7 @@ export class MessageHubService { */ public onlineUsers$ = this.onlineUsersSource.asObservable(); - constructor(private oidcService: OidcService) {} + constructor() {} /** * Tests that an event is of the type passed @@ -166,7 +165,7 @@ export class MessageHubService { createHubConnection(user: User) { this.hubConnection = new HubConnectionBuilder() .withUrl(this.hubUrl + 'messages', { - accessTokenFactory: () => this.oidcService.hasValidToken() ? this.oidcService.token : user.token + accessTokenFactory: () => user.oidcToken ?? user.token }) .withAutomaticReconnect() //.withStatefulReconnect() // Requires signalr@8.0 diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 04dfe3b4b..4710e5cbe 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -18,6 +18,7 @@ export class OidcService { private readonly oauth2 = inject(OAuthService); private readonly httpClient = inject(HttpClient); + private readonly accountService = inject(AccountService); private readonly destroyRef = inject(DestroyRef); private readonly toastR = inject(ToastrService); @@ -55,6 +56,17 @@ export class OidcService { }); } + this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { + if (event.type !== "token_refreshed" && event.type != 'token_received') return; + + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (!user) return; // Don't update tokens when we're not logged in. But what's going on? + + // TODO: Do we need to refresh the SignalR connection here? + user.oidcToken = this.token; + }); + }); + this.config().subscribe(oidcSetting => { if (!oidcSetting.authority) { this._loaded.set(true); @@ -109,8 +121,4 @@ export class OidcService { return this.oauth2.getAccessToken(); } - hasValidToken() { - return this.oauth2.hasValidAccessToken(); - } - } From 4c397e0af05f5a78cdb65f79e0611f91d4317593 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:41:09 +0200 Subject: [PATCH 20/27] Fix migration & OIDC not saving --- API/Data/Migrations/20250701154425_AppUserOwner.cs | 10 ---------- API/Services/SettingsService.cs | 2 ++ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/API/Data/Migrations/20250701154425_AppUserOwner.cs b/API/Data/Migrations/20250701154425_AppUserOwner.cs index 6dfc6e7e5..076e6af02 100644 --- a/API/Data/Migrations/20250701154425_AppUserOwner.cs +++ b/API/Data/Migrations/20250701154425_AppUserOwner.cs @@ -10,12 +10,6 @@ namespace API.Data.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.AddColumn( - name: "ExternalId", - table: "AspNetUsers", - type: "TEXT", - nullable: true); - migrationBuilder.AddColumn( name: "Owner", table: "AspNetUsers", @@ -27,10 +21,6 @@ namespace API.Data.Migrations /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "ExternalId", - table: "AspNetUsers"); - migrationBuilder.DropColumn( name: "Owner", table: "AspNetUsers"); diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 0719a6e8f..071968237 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -421,6 +421,7 @@ public class SettingsService : ISettingsService throw new KavitaException("oidc-invalid-authority"); } setting.Value = updateSettingsDto.OidcConfig.Authority; + Configuration.OidcAuthority = updateSettingsDto.OidcConfig.Authority; _unitOfWork.SettingsRepository.Update(setting); _logger.LogWarning("OIDC Authority is changing, clearing all external ids"); await _oidcService.ClearOidcIds(); @@ -430,6 +431,7 @@ public class SettingsService : ISettingsService if (setting.Key == ServerSettingKey.OidcClientId && setting.Value != updateSettingsDto.OidcConfig.ClientId) { setting.Value = updateSettingsDto.OidcConfig.ClientId; + Configuration.OidcClientId = updateSettingsDto.OidcConfig.ClientId; _unitOfWork.SettingsRepository.Update(setting); return; } From d2e1ca9078a9d6162fe83562be0dbb9fce754bce Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:19:57 +0200 Subject: [PATCH 21/27] Some debug logging to help in case of issues --- API/Extensions/ClaimsPrincipalExtensions.cs | 2 +- API/Services/OidcService.cs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index c1599caaf..cc78344dc 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -11,7 +11,7 @@ namespace API.Extensions; public static class ClaimsPrincipalExtensions { private const string NotAuthenticatedMessage = "User is not authenticated"; - private static readonly string EmailVerifiedClaimType = "email_verified"; + private const string EmailVerifiedClaimType = "email_verified"; /// /// Get's the authenticated user's username diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index aefde66a1..af236cc33 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -47,6 +47,7 @@ public class OidcService(ILogger logger, UserManager userM { private const string LibraryAccessPrefix = "library-"; private const string AgeRatingPrefix = "age-rating-"; + private const string IncludeUnknowns = AgeRatingPrefix + "include-unknowns"; public async Task LoginOrCreate(ClaimsPrincipal principal) { @@ -108,6 +109,7 @@ public class OidcService(ILogger logger, UserManager userM var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null; + // TODO?: Try one by one, for more chance of a nicer username var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Name); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); @@ -147,6 +149,7 @@ public class OidcService(ILogger logger, UserManager userM AddDefaultStreamsToUser(user, mapper); await AddDefaultReadingProfileToUser(user); + await SyncUserSettings(settings, claimsPrincipal, user); await SetDefaults(settings, user); @@ -179,9 +182,10 @@ public class OidcService(ILogger logger, UserManager userM var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); if (defaultAdminUser.Id == user.Id) return; + logger.LogDebug("Syncing user {UserId} from OIDC", user.Id); await SyncRoles(claimsPrincipal, user); await SyncLibraries(claimsPrincipal, user); - SyncAgeRating(claimsPrincipal, user); + SyncAgeRestriction(claimsPrincipal, user); if (unitOfWork.HasChanges()) @@ -191,6 +195,7 @@ public class OidcService(ILogger logger, UserManager userM private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user) { var roles = claimsPrincipal.GetAccessRoles(); + logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles); var errors = await accountService.UpdateRolesForUser(user, roles); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); } @@ -204,6 +209,7 @@ public class OidcService(ILogger logger, UserManager userM .Where(r => r.Value.StartsWith(LibraryAccessPrefix)) .Select(r => r.Value.TrimPrefix(LibraryAccessPrefix)) .ToList(); + logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess); if (libraryAccess.Count == 0 && !hasAdminRole) return; var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); @@ -212,7 +218,7 @@ public class OidcService(ILogger logger, UserManager userM await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); } - private static void SyncAgeRating(ClaimsPrincipal claimsPrincipal, AppUser user) + private void SyncAgeRestriction(ClaimsPrincipal claimsPrincipal, AppUser user) { var ageRatings = claimsPrincipal @@ -220,6 +226,7 @@ public class OidcService(ILogger logger, UserManager userM .Where(r => r.Value.StartsWith(AgeRatingPrefix)) .Select(r => r.Value.TrimPrefix(AgeRatingPrefix)) .ToList(); + logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings); if (ageRatings.Count == 0) return; var highestAgeRating = AgeRating.Unknown; @@ -235,6 +242,7 @@ public class OidcService(ILogger logger, UserManager userM } user.AgeRestriction = highestAgeRating; + user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns); } // DUPLICATED CODE From b6bfc65bc44a4c8890f3e87c6767b38c897fa519 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 6 Jul 2025 14:39:05 +0200 Subject: [PATCH 22/27] Share Stats about OIDC use --- API/DTOs/Stats/V3/ServerInfoV3Dto.cs | 4 ++++ API/DTOs/Stats/V3/UserStatV3.cs | 5 +++++ API/Entities/AppUser.cs | 3 ++- API/Services/Tasks/StatsService.cs | 5 ++++- 4 files changed, 15 insertions(+), 2 deletions(-) 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..745fd39b6 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 AppUserOwner Owner { get; set; } } diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index fd37126ab..3952be678 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -96,8 +96,9 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// public string? ExternalId { get; set; } /// - /// Describes how the account was created + /// Describes who manages the account (may further depend on other settings) /// + /// Always fallbacks to native public AppUserOwner Owner { get; set; } = AppUserOwner.Native; diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 5d5df6647..50e13e65d 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; @@ -353,7 +354,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.Owner = user.Owner; if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries)) { From 6e72c74fdefbf9cbd63937e2493eb6e52b6a00f9 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:42:21 +0200 Subject: [PATCH 23/27] Cleanup and some edge case fixes --- API/Controllers/AccountController.cs | 64 +++++------ API/Controllers/OidcControlller.cs | 9 ++ API/DTOs/Settings/OidcPublicConfigDto.cs | 2 +- API/Data/Seed.cs | 2 +- API/Extensions/EnumerableExtensions.cs | 6 + API/Extensions/IdentityServiceExtensions.cs | 14 +-- API/I18N/en.json | 1 + API/Services/AccountService.cs | 43 +++++-- API/Services/OidcService.cs | 106 +++++++++--------- API/Services/SettingsService.cs | 12 +- UI/Web/src/app/_models/user.ts | 2 +- UI/Web/src/app/_services/account.service.ts | 4 - UI/Web/src/app/_services/oidc.service.ts | 14 ++- UI/Web/src/app/admin/_models/oidc-config.ts | 26 ++--- .../admin/edit-user/edit-user.component.ts | 11 +- .../pdf-reader/pdf-reader.component.html | 2 +- UI/Web/src/assets/langs/en.json | 2 +- 17 files changed, 172 insertions(+), 148 deletions(-) diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 76ccd95ea..640427114 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -77,14 +77,24 @@ public class AccountController : BaseApiController _oidcService = oidcService; } + /// + /// Returns the current user, as it would from login + /// + /// + /// + /// Also throws UnauthorizedAccessException if the users is missing the Login role + /// Syncs Oidc settings if enabled, and user is Oidc owned [HttpGet] public async Task> GetCurrentUserAsync() { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); if (user == null) throw new UnauthorizedAccessException(); - var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; - await _oidcService.SyncUserSettings(oidcSettings, User, user); + if (user.Owner == AppUserOwner.OpenIdConnect) + { + var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; + await _oidcService.SyncUserSettings(oidcSettings, User, user); + } var roles = await _userManager.GetRolesAsync(user); if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); @@ -169,10 +179,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")); @@ -243,7 +253,7 @@ public class AccountController : BaseApiController 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 funcitonal, and if we're not logging in via ApiKey + // 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")); @@ -545,19 +555,24 @@ 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")); - // Disallowed editing users synced via OIDC + // Disallowed editing users owned by OIDC var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; - if (user.Owner == AppUserOwner.OpenIdConnect && - dto.Owner != AppUserOwner.Native && - oidcSettings.SyncUserSettings) + if (user.Owner == AppUserOwner.OpenIdConnect && dto.Owner != AppUserOwner.Native && oidcSettings.SyncUserSettings) { return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed")); } var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser(); - if (user.Id != defaultAdminUser.Id) + if (user.Id == defaultAdminUser.Id && dto.Owner != AppUserOwner.Native) { - user.Owner = dto.Owner; + return BadRequest(await _localizationService.Translate(User.GetUserId(), "cannot-change-ownership-original-user")); + } + + if (user.Owner == AppUserOwner.OpenIdConnect) + { + // Do not change any other fields when the user is owned by OIDC + await _unitOfWork.CommitAsync(); + return Ok(); } // Check if username is changing @@ -713,10 +728,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; @@ -815,29 +830,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/OidcControlller.cs b/API/Controllers/OidcControlller.cs index e0ba62bcc..4b74688d6 100644 --- a/API/Controllers/OidcControlller.cs +++ b/API/Controllers/OidcControlller.cs @@ -13,6 +13,10 @@ public class OidcController(ILogger logger, IUnitOfWork unitOfWo IMapper mapper, ISettingsService settingsService): BaseApiController { + /// + /// Retrieve publicly required configuration regarding Oidc + /// + /// [AllowAnonymous] [HttpGet("config")] public async Task> GetOidcConfig() @@ -21,6 +25,11 @@ public class OidcController(ILogger logger, IUnitOfWork unitOfWo return Ok(mapper.Map(settings.OidcConfig)); } + /// + /// Validate if the given authority is reachable from the server + /// + /// + /// [Authorize("RequireAdminRole")] [HttpPost("is-valid-authority")] public async Task> IsValidAuthority([FromBody] IsValidAuthorityBody authority) diff --git a/API/DTOs/Settings/OidcPublicConfigDto.cs b/API/DTOs/Settings/OidcPublicConfigDto.cs index 171e46d7e..7861161ea 100644 --- a/API/DTOs/Settings/OidcPublicConfigDto.cs +++ b/API/DTOs/Settings/OidcPublicConfigDto.cs @@ -10,7 +10,7 @@ public record OidcPublicConfigDto /// public string? ClientId { get; set; } /// - /// Optional OpenID Connect ClientSecret, required if authority is set + /// Automatically redirect to the Oidc login screen /// public bool AutoLogin { get; set; } /// diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 12084e20a..914d79b9a 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -254,7 +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.OidcAuthority, Value = Configuration.OidcAuthority }, + new() { Key = ServerSettingKey.OidcAuthority, Value = Configuration.OidcAuthority}, new() { Key = ServerSettingKey.OidcClientId, Value = Configuration.OidcClientId}, new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())}, diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 9bc06bab4..bcdd2808b 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 @@ -68,4 +69,9 @@ public static class EnumerableExtensions return q; } + + public static string AsJoinedString(this IEnumerable errors) + { + return string.Join(",", errors.Select(e => e.Description)); + } } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index e5f03430c..26958193a 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -146,14 +146,10 @@ public static class IdentityServiceExtensions }); - services.AddAuthorization(opt => - { - 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.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; } @@ -163,7 +159,6 @@ public static class IdentityServiceExtensions if (ctx.Principal == null) return; var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); - var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService(); var user = await oidcService.LoginOrCreate(ctx.Principal); if (user == null) { @@ -180,6 +175,7 @@ public static class IdentityServiceExtensions new(ClaimTypes.Name, user.UserName ?? string.Empty), }; + var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService(); var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings) { diff --git a/API/I18N/en.json b/API/I18N/en.json index d1e8346ac..e598be54f 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -19,6 +19,7 @@ "age-restriction-update": "There was an error updating the age restriction", "no-user": "User does not exist", "oidc-managed": "This user is managed by OIDC, cannot edit", + "cannot-change-ownership-original-user": "Ownership 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", diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index e331030ee..09f0df583 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -2,19 +2,20 @@ using System.Collections.Generic; using System.Linq; 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; @@ -41,6 +42,8 @@ public interface IAccountService /// Ensure that the users SideNavStreams are loaded 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 @@ -48,13 +51,16 @@ public class AccountService : IAccountService 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 AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) + public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork, + IMapper mapper) { _userManager = userManager; _logger = logger; _unitOfWork = unitOfWork; + _mapper = mapper; } public async Task> ChangeUserPassword(AppUser user, string newPassword) @@ -158,11 +164,11 @@ public class AccountService : IAccountService public async Task UpdateLibrariesForUser(AppUser user, IList librariesIds, bool hasAdminRole) { - var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList(); List libraries; if (hasAdminRole) { - _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + _logger.LogInformation("{UserName} is admin. Granting access to all libraries", user.UserName); libraries = allLibraries; } @@ -176,7 +182,7 @@ public class AccountService : IAccountService user.RemoveSideNavFromLibrary(lib); } - libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(librariesIds, LibraryIncludes.AppUser)).ToList(); + libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList(); } foreach (var lib in libraries) @@ -207,4 +213,27 @@ public class AccountService : IAccountService 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(); + } } diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index af236cc33..a95361f58 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -12,7 +12,6 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; -using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -43,7 +42,7 @@ public interface IOidcService } public class OidcService(ILogger logger, UserManager userManager, - IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService + IUnitOfWork unitOfWork, IAccountService accountService): IOidcService { private const string LibraryAccessPrefix = "library-"; private const string AgeRatingPrefix = "age-rating-"; @@ -71,7 +70,7 @@ public class OidcService(ILogger logger, UserManager userM user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); if (user != null) { - logger.LogInformation("User {Name} has matched on email to {ExternalId}", user.UserName, externalId); + logger.LogInformation("User {UserName} has matched on email to {ExternalId}", user.Id, externalId); user.ExternalId = externalId; await unitOfWork.CommitAsync(); return user; @@ -85,7 +84,7 @@ public class OidcService(ILogger logger, UserManager userM if (user == null) return null; var roles = await userManager.GetRolesAsync(user); - if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) + if (roles.Count == 0 || !roles.Contains(PolicyConstants.LoginRole)) throw new KavitaException("errors.oidc.disabled-account"); return user; @@ -102,6 +101,30 @@ public class OidcService(ILogger logger, UserManager userM await unitOfWork.CommitAsync(); } + private async Task FindBestAvailableName(ClaimsPrincipal claimsPrincipal) + { + var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername); + if (await IsNameAvailable(name)) return name; + + name = claimsPrincipal.FindFirstValue(ClaimTypes.Name); + if (await IsNameAvailable(name)) return name; + + name = claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); + if (await IsNameAvailable(name)) return name; + + name = claimsPrincipal.FindFirstValue(ClaimTypes.Surname); + if (await IsNameAvailable(name)) return name; + + return null; + } + + private async Task IsNameAvailable(string? name) + { + if (string.IsNullOrEmpty(name)) return false; + + return await userManager.FindByNameAsync(name) == null; + } + private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId) { if (!settings.ProvisionAccounts) return null; @@ -109,20 +132,7 @@ public class OidcService(ILogger logger, UserManager userM var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null; - // TODO?: Try one by one, for more chance of a nicer username - var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername); - name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Name); - name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); - name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname); - name ??= emailClaim.Value; - - var other = await userManager.FindByNameAsync(name); - if (other != null) - { - // We match by email, so this will always be unique - name = emailClaim.Value; - } - + var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value; logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId); // TODO: Move to account service, as we're sharing code with AccountController @@ -147,8 +157,8 @@ public class OidcService(ILogger logger, UserManager userM user.ExternalId = externalId; user.Owner = AppUserOwner.OpenIdConnect; - AddDefaultStreamsToUser(user, mapper); - await AddDefaultReadingProfileToUser(user); + accountService.AddDefaultStreamsToUser(user); + await accountService.AddDefaultReadingProfileToUser(user); await SyncUserSettings(settings, claimsPrincipal, user); await SetDefaults(settings, user); @@ -161,6 +171,9 @@ public class OidcService(ILogger logger, UserManager userM { 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.DefaultAgeRating, settings.DefaultIncludeUnknowns); + // Assign roles var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); @@ -179,13 +192,14 @@ public class OidcService(ILogger logger, UserManager userM { 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); + logger.LogInformation("Syncing user {UserName} from OIDC", user.UserName); await SyncRoles(claimsPrincipal, user); await SyncLibraries(claimsPrincipal, user); - SyncAgeRestriction(claimsPrincipal, user); + await SyncAgeRestriction(claimsPrincipal, user); if (unitOfWork.HasChanges()) @@ -195,39 +209,45 @@ public class OidcService(ILogger logger, UserManager userM private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user) { var roles = claimsPrincipal.GetAccessRoles(); - logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles); + logger.LogDebug("Syncing access roles for user {UserName}, found roles {Roles}", user.UserName, roles); + var errors = await accountService.UpdateRolesForUser(user, roles); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); } private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) { - var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); - var libraryAccess = claimsPrincipal .FindAll(ClaimTypes.Role) .Where(r => r.Value.StartsWith(LibraryAccessPrefix)) .Select(r => r.Value.TrimPrefix(LibraryAccessPrefix)) .ToList(); - logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess); - if (libraryAccess.Count == 0 && !hasAdminRole) return; + + logger.LogDebug("Syncing libraries for user {UserName}, found library roles {Roles}", user.UserName, libraryAccess); var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList(); + var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); } - private void SyncAgeRestriction(ClaimsPrincipal claimsPrincipal, AppUser user) + private async Task SyncAgeRestriction(ClaimsPrincipal claimsPrincipal, AppUser user) { + if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) + { + logger.LogInformation("User {UserName} is admin, granting access to all age ratings", user.UserName); + user.AgeRestriction = AgeRating.NotApplicable; + user.AgeRestrictionIncludeUnknowns = true; + return; + } var ageRatings = claimsPrincipal .FindAll(ClaimTypes.Role) .Where(r => r.Value.StartsWith(AgeRatingPrefix)) .Select(r => r.Value.TrimPrefix(AgeRatingPrefix)) .ToList(); - logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings); - if (ageRatings.Count == 0) return; + logger.LogDebug("Syncing age restriction for user {UserName}, found restrictions {Restrictions}", user.UserName, ageRatings); var highestAgeRating = AgeRating.Unknown; @@ -243,29 +263,9 @@ public class OidcService(ILogger logger, UserManager userM user.AgeRestriction = highestAgeRating; user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns); + + logger.LogDebug("Synced age restriction for user {UserName}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}", + user.UserName, ageRatings, user.AgeRestrictionIncludeUnknowns); } - // DUPLICATED CODE - private static void AddDefaultStreamsToUser(AppUser user, IMapper mapper) - { - 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); - } - } - - 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(); - } } diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 071968237..d41684b78 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -16,7 +16,6 @@ using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -367,10 +366,9 @@ public class SettingsService : ISettingsService var url = authority + "/.well-known/openid-configuration"; try { - //await url.GetJsonAsync(); - //return true; - var res = await url.GetAsync(); - return res.StatusCode == 200; + var json = await url.GetStringAsync(); + var config = OpenIdConnectConfiguration.Create(json); + return config.Issuer == Configuration.OidcAuthority; } catch (Exception e) { @@ -420,9 +418,11 @@ public class SettingsService : ISettingsService { throw new KavitaException("oidc-invalid-authority"); } + setting.Value = updateSettingsDto.OidcConfig.Authority; Configuration.OidcAuthority = updateSettingsDto.OidcConfig.Authority; _unitOfWork.SettingsRepository.Update(setting); + _logger.LogWarning("OIDC Authority is changing, clearing all external ids"); await _oidcService.ClearOidcIds(); return; @@ -441,7 +441,7 @@ public class SettingsService : ISettingsService var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); if (setting.Value == newValue) return; - setting.Value = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); + setting.Value = newValue; _unitOfWork.SettingsRepository.Update(setting); } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 5a8cb70b0..7ffbbdf9c 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -6,7 +6,7 @@ export interface User { username: string; // This is set by the oidc service, will always take precedence over the Kavita generated token // When set, the refresh logic for the Kavita token will not run - oidcToken: string; + oidcToken?: string; token: string; refreshToken: string; roles: string[]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 52a618fc5..7395d106c 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -92,10 +92,6 @@ export class AccountService { }); } - oidcEnabled() { - return this.httpClient.get(this.baseUrl + "oidc/enabled"); - } - canInvokeAction(user: User, action: Action) { const isAdmin = this.hasAdminRole(user); const canDownload = this.hasDownloadRole(user); diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 4710e5cbe..c8fc7ec05 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -10,6 +10,7 @@ import {take} from "rxjs/operators"; import {ToastrService} from "ngx-toastr"; import {translate} from "@jsverse/transloco"; import {APP_BASE_HREF} from "@angular/common"; +import {MessageHubService} from "./message-hub.service"; @Injectable({ providedIn: 'root' @@ -21,6 +22,7 @@ export class OidcService { private readonly accountService = inject(AccountService); private readonly destroyRef = inject(DestroyRef); private readonly toastR = inject(ToastrService); + private readonly messageHub = inject(MessageHubService); protected readonly baseUrl = inject(APP_BASE_HREF); apiBaseUrl = environment.apiUrl; @@ -33,7 +35,7 @@ export class OidcService { public readonly loaded$ = toObservable(this.loaded); /** - * OIDC discovery document has been loaded, and login tried and OIDC has been set up + * OIDC discovery document has been loaded, login tried and OIDC has been set up */ private readonly _ready = signal(false); public readonly ready = this._ready.asReadonly(); @@ -62,12 +64,15 @@ export class OidcService { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (!user) return; // Don't update tokens when we're not logged in. But what's going on? - // TODO: Do we need to refresh the SignalR connection here? user.oidcToken = this.token; + this.messageHub.stopHubConnection(); + this.messageHub.createHubConnection(user); }); }); - this.config().subscribe(oidcSetting => { + this.getPublicOidcConfig().subscribe(oidcSetting => { + this._settings.set(oidcSetting); + if (!oidcSetting.authority) { this._loaded.set(true); return @@ -86,7 +91,6 @@ export class OidcService { // Not all OIDC providers follow this nicely strictDiscoveryDocumentValidation: false, }); - this._settings.set(oidcSetting); this.oauth2.setupAutomaticSilentRefresh(); from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ @@ -113,7 +117,7 @@ export class OidcService { } } - config() { + getPublicOidcConfig() { return this.httpClient.get(this.apiBaseUrl + "oidc/config"); } diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index d48cdf088..f67d8c3e1 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -1,20 +1,5 @@ import {AgeRating} from "../../_models/metadata/age-rating"; -export interface OidcConfig { - authority: string; - clientId: string; - provisionAccounts: boolean; - requireVerifiedEmail: boolean; - syncUserSettings: boolean; - autoLogin: boolean; - disablePasswordAuthentication: boolean; - providerName: string; - defaultRoles: string[]; - defaultLibraries: number[]; - defaultAgeRating: AgeRating; - defaultIncludeUnknowns: boolean; -} - export interface OidcPublicConfig { authority: string; clientId: string; @@ -22,3 +7,14 @@ export interface OidcPublicConfig { disablePasswordAuthentication: boolean; providerName: string; } + +export interface OidcConfig extends OidcPublicConfig { + provisionAccounts: boolean; + requireVerifiedEmail: boolean; + syncUserSettings: boolean; + defaultRoles: string[]; + defaultLibraries: number[]; + defaultAgeRating: AgeRating; + defaultIncludeUnknowns: boolean; +} + diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index 124810678..28832d1aa 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -3,10 +3,9 @@ import { ChangeDetectorRef, Component, computed, - DestroyRef, effect, + DestroyRef, inject, - input, - Input, model, + model, OnInit } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; @@ -27,8 +26,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ServerSettings} from "../_models/server-settings"; import {UserOwner, UserOwners} from "../../_models/user"; import {UserOwnerPipe} from "../../_pipes/user-owner.pipe"; -import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; -import {OwnerIconComponent} from "../../shared/_components/owner-icon/owner-icon.component"; const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/; const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -37,7 +34,7 @@ const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; selector: 'app-edit-user', templateUrl: './edit-user.component.html', styleUrls: ['./edit-user.component.scss'], - imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, UserOwnerPipe, SettingItemComponent, OwnerIconComponent], + imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, UserOwnerPipe], changeDetection: ChangeDetectionStrategy.OnPush }) export class EditUserComponent implements OnInit { @@ -80,8 +77,6 @@ export class EditUserComponent implements OnInit { this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.addControl('owner', new FormControl(this.member().owner, [Validators.required])); - // TODO: Rework, bad hack - // Work around isLocked so we're able to downgrade users this.userForm.get('owner')!.valueChanges.pipe( tap(value => { const newOwner = parseInt(value, 10) as UserOwner; diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html index 7e75e541c..c3ce4bf50 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html @@ -17,7 +17,7 @@ Date: Sun, 6 Jul 2025 16:50:56 +0200 Subject: [PATCH 24/27] Actually update the owner --- API/Controllers/AccountController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 640427114..e65193001 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -568,6 +568,8 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "cannot-change-ownership-original-user")); } + user.Owner = dto.Owner; + if (user.Owner == AppUserOwner.OpenIdConnect) { // Do not change any other fields when the user is owned by OIDC From 5e367418d1c62ab6447b2ca990da1d67ab4a15bd Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:06:01 +0200 Subject: [PATCH 25/27] Slight renaming --- UI/Web/src/app/_services/oidc.service.ts | 15 +++++++-------- UI/Web/src/app/app.component.ts | 4 ++-- .../pdf-reader/pdf-reader.component.html | 2 +- .../user-login/user-login.component.html | 2 +- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index c8fc7ec05..4ebc1fc50 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -1,6 +1,6 @@ -import {DestroyRef, effect, inject, Injectable, signal} from '@angular/core'; +import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core'; import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc"; -import {BehaviorSubject, from, Observable} from "rxjs"; +import {from} from "rxjs"; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {OidcPublicConfig} from "../admin/_models/oidc-config"; @@ -34,11 +34,11 @@ export class OidcService { public readonly loaded = this._loaded.asReadonly(); public readonly loaded$ = toObservable(this.loaded); - /** - * OIDC discovery document has been loaded, login tried and OIDC has been set up - */ - private readonly _ready = signal(false); - public readonly ready = this._ready.asReadonly(); + public readonly inUse = computed(() => { + const loaded = this.loaded(); + const settings = this.settings(); + return loaded && settings && settings.authority.trim() !== ''; + }); /** * Public OIDC settings @@ -96,7 +96,6 @@ export class OidcService { from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ next: _ => { this._loaded.set(true); - this._ready.set(true); }, error: error => { console.log(error); diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 5fcad6f8e..9f143f785 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -102,9 +102,9 @@ export class AppComponent implements OnInit { // Login automatically when a token is available effect(() => { - const ready = this.oidcService.ready(); + const inUse = this.oidcService.inUse(); const user = this.accountService.currentUserSignal(); - if (!ready || !this.oidcService.token || user) return; + if (!inUse || !this.oidcService.token || user) return; this.accountService.loginByToken(this.oidcService.token).subscribe({ next: () => { diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html index c3ce4bf50..e8ae61abf 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html @@ -17,7 +17,7 @@ } - @if (oidcService.ready()) { + @if (oidcService.inUse()) { 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/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 50e13e65d..57557da94 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -309,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); From e5c716d2343865fc1c0da9fbf0436f43bc748f09 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:42:07 +0200 Subject: [PATCH 27/27] Tweak refresh logic for OIDC --- UI/Web/src/app/_services/oidc.service.ts | 39 ++++++++++++------------ UI/Web/src/app/app.component.ts | 17 ++++++----- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/UI/Web/src/app/_services/oidc.service.ts b/UI/Web/src/app/_services/oidc.service.ts index 4ebc1fc50..aac2ae9ce 100644 --- a/UI/Web/src/app/_services/oidc.service.ts +++ b/UI/Web/src/app/_services/oidc.service.ts @@ -4,13 +4,20 @@ import {from} from "rxjs"; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {OidcPublicConfig} from "../admin/_models/oidc-config"; -import {AccountService} from "./account.service"; import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop"; -import {take} from "rxjs/operators"; import {ToastrService} from "ngx-toastr"; import {translate} from "@jsverse/transloco"; import {APP_BASE_HREF} from "@angular/common"; -import {MessageHubService} from "./message-hub.service"; + +/** + * Enum mirror of angular-oauth2-oidc events which are used in Kavita + */ +export enum OidcEvents { + /** + * Fired on token refresh, and when the first token is recieved + */ + TokenRefreshed = "token_refreshed" +} @Injectable({ providedIn: 'root' @@ -19,14 +26,14 @@ export class OidcService { private readonly oauth2 = inject(OAuthService); private readonly httpClient = inject(HttpClient); - private readonly accountService = inject(AccountService); private readonly destroyRef = inject(DestroyRef); private readonly toastR = inject(ToastrService); - private readonly messageHub = inject(MessageHubService); protected readonly baseUrl = inject(APP_BASE_HREF); apiBaseUrl = environment.apiUrl; + public events$ = this.oauth2.events; + /** * True when the OIDC discovery document has been loaded, and login tried. Or no OIDC has been set up */ @@ -47,29 +54,19 @@ export class OidcService { public readonly settings = this._settings.asReadonly(); constructor() { + this.oauth2.setStorage(localStorage); + // log events in dev if (!environment.production) { this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { if (event instanceof OAuthErrorEvent) { - console.error('OAuthErrorEvent Object:', event); + console.error('OAuthErrorEvent:', event); } else { - console.debug('OAuthEvent Object:', event); + console.debug('OAuthEvent:', event); } }); } - this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { - if (event.type !== "token_refreshed" && event.type != 'token_received') return; - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (!user) return; // Don't update tokens when we're not logged in. But what's going on? - - user.oidcToken = this.token; - this.messageHub.stopHubConnection(); - this.messageHub.createHubConnection(user); - }); - }); - this.getPublicOidcConfig().subscribe(oidcSetting => { this._settings.set(oidcSetting); @@ -96,6 +93,10 @@ export class OidcService { from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ next: _ => { this._loaded.set(true); + + if (!this.oauth2.hasValidAccessToken() && this.oauth2.getRefreshToken()) { + this.oauth2.refreshToken().catch(err => console.error("failed to refresh token on startup", err)); + } }, error: error => { console.log(error); diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 9f143f785..a4953392e 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -25,7 +25,7 @@ import {TranslocoService} from "@jsverse/transloco"; import {VersionService} from "./_services/version.service"; import {LicenseService} from "./_services/license.service"; import {LocalizationService} from "./_services/localization.service"; -import {OidcService} from "./_services/oidc.service"; +import {OidcEvents, OidcService} from "./_services/oidc.service"; @Component({ selector: 'app-root', @@ -52,7 +52,7 @@ export class AppComponent implements OnInit { private readonly document = inject(DOCUMENT); private readonly translocoService = inject(TranslocoService); private readonly versionService = inject(VersionService); // Needs to be injected to run background job - private readonly oidcService = inject(OidcService); // Needed to auto login + private readonly oidcService = inject(OidcService); private readonly licenseService = inject(LicenseService); private readonly localizationService = inject(LocalizationService); @@ -100,11 +100,15 @@ export class AppComponent implements OnInit { this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup - // Login automatically when a token is available - effect(() => { - const inUse = this.oidcService.inUse(); + // Update token, or login when one becomes available + this.oidcService.events$.subscribe(event => { + if (event.type !== OidcEvents.TokenRefreshed) return; + const user = this.accountService.currentUserSignal(); - if (!inUse || !this.oidcService.token || user) return; + if (user) { + user.oidcToken = this.oidcService.token; + return; + } this.accountService.loginByToken(this.oidcService.token).subscribe({ next: () => { @@ -115,7 +119,6 @@ export class AppComponent implements OnInit { } }); }); - } @HostListener('window:resize', ['$event'])