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)