OpenID Connect support (#3975)

Co-authored-by: DieselTech <30128380+DieselTech@users.noreply.github.com>
Co-authored-by: majora2007 <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-08-03 14:04:33 +02:00 committed by GitHub
parent a9e7581e89
commit b5bfd341d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 7604 additions and 279 deletions

View File

@ -0,0 +1,22 @@
#nullable enable
using System;
using API.Entities.Enums;
using API.Extensions;
using Xunit;
namespace API.Tests.Extensions;
public class EnumExtensionTests
{
[Theory]
[InlineData("Early Childhood", AgeRating.EarlyChildhood, true)]
[InlineData("M", AgeRating.Mature, true)]
[InlineData("ThisIsNotAnAgeRating", default(AgeRating), false)]
public void TryParse<TEnum>(string? value, TEnum expected, bool success) where TEnum : struct, Enum
{
Assert.Equal(EnumExtensions.TryParse(value, out TEnum got), success);
Assert.Equal(expected, got);
}
}

View File

@ -0,0 +1,298 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Tasks.Scanner;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class AccountServiceTests: AbstractDbTest
{
[Theory]
[InlineData("admin", true)]
[InlineData("^^$SomeBadChars", false)]
[InlineData("Lisa2003", true)]
[InlineData("Kraft Lawrance", false)]
public async Task ValidateUsername_Regex(string username, bool valid)
{
await ResetDb();
var (_, accountService, _, _) = await Setup();
Assert.Equal(valid, !(await accountService.ValidateUsername(username)).Any());
}
[Fact]
public async Task ChangeIdentityProvider_Throws_WhenDefaultAdminUser()
{
await ResetDb();
var (_, accountService, _, _) = await Setup();
var defaultAdmin = await UnitOfWork.UserRepository.GetDefaultAdminUser();
await Assert.ThrowsAsync<KavitaException>(() =>
accountService.ChangeIdentityProvider(defaultAdmin.Id, defaultAdmin, IdentityProvider.Kavita));
}
[Fact]
public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsIsFalse()
{
await ResetDb();
var (user, accountService, _, _) = await Setup();
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita);
Assert.False(result);
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider);
}
[Fact]
public async Task ChangeIdentityProvider_Throws_WhenUserIsOidcManaged_AndNoChange()
{
await ResetDb();
var (user, accountService, _, settingsService) = await Setup();
user.IdentityProvider = IdentityProvider.OpenIdConnect;
await UnitOfWork.CommitAsync();
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
settings.OidcConfig.SyncUserSettings = true;
await settingsService.UpdateSettings(settings);
await Assert.ThrowsAsync<KavitaException>(() =>
accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect));
}
[Fact]
public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsTrue_AndChangeIsAllowed()
{
await ResetDb();
var (user, accountService, _, settingsService) = await Setup();
user.IdentityProvider = IdentityProvider.OpenIdConnect;
await UnitOfWork.CommitAsync();
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
settings.OidcConfig.SyncUserSettings = true;
await settingsService.UpdateSettings(settings);
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita);
Assert.False(result);
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider);
}
[Fact]
public async Task ChangeIdentityProvider_ReturnsTrue_WhenChangedToOidc()
{
await ResetDb();
var (user, accountService, _, settingsService) = await Setup();
user.IdentityProvider = IdentityProvider.Kavita;
await UnitOfWork.CommitAsync();
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
settings.OidcConfig.SyncUserSettings = true;
await settingsService.UpdateSettings(settings);
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect);
Assert.True(result);
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(IdentityProvider.OpenIdConnect, updated.IdentityProvider);
}
[Fact]
public async Task UpdateLibrariesForUser_GrantsAccessToAllLibraries_WhenAdmin()
{
await ResetDb();
var (user, accountService, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
await accountService.UpdateLibrariesForUser(user, new List<int>(), hasAdminRole: true);
await UnitOfWork.CommitAsync();
var userLibs = await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
Assert.Equal(2, userLibs.Count());
}
[Fact]
public async Task UpdateLibrariesForUser_GrantsAccessToSelectedLibraries_WhenNotAdmin()
{
await ResetDb();
var (user, accountService, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id }, hasAdminRole: false);
await UnitOfWork.CommitAsync();
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
Assert.Single(userLibs);
Assert.Equal(mangaLib.Id, userLibs.First().Id);
}
[Fact]
public async Task UpdateLibrariesForUser_RemovesAccessFromUnselectedLibraries_WhenNotAdmin()
{
await ResetDb();
var (user, accountService, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
// Grant access to both libraries
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false);
await UnitOfWork.CommitAsync();
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
Assert.Equal(2, userLibs.Count);
// Now restrict access to only light novels
await accountService.UpdateLibrariesForUser(user, new List<int> { lightNovelsLib.Id }, hasAdminRole: false);
await UnitOfWork.CommitAsync();
userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
Assert.Single(userLibs);
Assert.Equal(lightNovelsLib.Id, userLibs.First().Id);
}
[Fact]
public async Task UpdateLibrariesForUser_GrantsNoLibraries_WhenNoneSelected_AndNotAdmin()
{
await ResetDb();
var (user, accountService, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
// Initially grant access to both libraries
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false);
await UnitOfWork.CommitAsync();
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
Assert.Equal(2, userLibs.Count);
// Now revoke all access by passing empty list
await accountService.UpdateLibrariesForUser(user, new List<int>(), hasAdminRole: false);
await UnitOfWork.CommitAsync();
userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
Assert.Empty(userLibs);
}
private async Task<(AppUser, IAccountService, UserManager<AppUser>, SettingsService)> Setup()
{
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
.WithRole(PolicyConstants.AdminRole)
.Build();
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
var roleStore = new RoleStore<
AppRole,
DataContext,
int,
IdentityUserRole<int>,
IdentityRoleClaim<int>
>(Context);
var roleManager = new RoleManager<AppRole>(
roleStore,
[new RoleValidator<AppRole>()],
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
Substitute.For<ILogger<RoleManager<AppRole>>>());
foreach (var role in PolicyConstants.ValidRoles)
{
if (!await roleManager.RoleExistsAsync(role))
{
await roleManager.CreateAsync(new AppRole
{
Name = role,
});
}
}
var userStore = new UserStore<
AppUser,
AppRole,
DataContext,
int,
IdentityUserClaim<int>,
AppUserRole,
IdentityUserLogin<int>,
IdentityUserToken<int>,
IdentityRoleClaim<int>
>(Context);
var userManager = new UserManager<AppUser>(userStore,
new OptionsWrapper<IdentityOptions>(new IdentityOptions()),
new PasswordHasher<AppUser>(),
[new UserValidator<AppUser>()],
[new PasswordValidator<AppUser>()],
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
Substitute.For<ILogger<UserManager<AppUser>>>());
// Create users with the UserManager such that the SecurityStamp is set
await userManager.CreateAsync(user);
await userManager.CreateAsync(defaultAdmin);
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), UnitOfWork, Mapper, Substitute.For<ILocalizationService>());
var settingsService = new SettingsService(UnitOfWork, Substitute.For<IDirectoryService>(), Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SettingsService>> (), Substitute.For<IOidcService>());
user = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.SideNavStreams);
return (user, accountService, userManager, settingsService);
}
protected override async Task ResetDb()
{
Context.AppUser.RemoveRange(Context.AppUser);
Context.Library.RemoveRange(Context.Library);
await UnitOfWork.CommitAsync();
}
}

View File

@ -0,0 +1,582 @@
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class OidcServiceTests: AbstractDbTest
{
[Fact]
public async Task UserSync_Username()
{
await ResetDb();
var (oidcService, _, _, userManager) = await Setup();
var user = new AppUserBuilder("holo", "holo@localhost").Build();
var res = await userManager.CreateAsync(user);
Assert.Empty(res.Errors);
Assert.True(res.Succeeded);
var claims = new List<Claim>()
{
new (ClaimTypes.Name, "amelia"),
new (ClaimTypes.GivenName, "Lawrence"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
// name is updated as the current username is not found, amelia is skipped as it is alredy in use
await oidcService.SyncUserSettings(null!, settings, principal, user);
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal("Lawrence", user.UserName);
claims = new List<Claim>()
{
new (ClaimTypes.Name, "amelia"),
new (ClaimTypes.GivenName, "Lawrence"),
new (ClaimTypes.Surname, "Norah"),
};
identity = new ClaimsIdentity(claims);
principal = new ClaimsPrincipal(identity);
// Ensure a name longer down the list isn't picked if the current username is found
await oidcService.SyncUserSettings(null!, settings, principal, user);
dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal("Lawrence", user.UserName);
}
[Fact]
public async Task UserSync_CustomClaim()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
const string claim = "groups";
var claims = new List<Claim>()
{
new (claim, PolicyConstants.LoginRole),
new (claim, PolicyConstants.DownloadRole),
new (ClaimTypes.Role, PolicyConstants.PromoteRole),
new (claim, OidcService.AgeRestrictionPrefix + "M"),
new (claim, OidcService.LibraryAccessPrefix + "Manga"),
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
RolesClaim = claim,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
// Check correct roles assigned
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
Assert.Contains(PolicyConstants.LoginRole, userRoles);
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles);
// Check correct libraries
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
Assert.Single(libraries);
Assert.Contains(mangaLib.Name, libraries);
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
// Check correct age restrictions
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
Assert.False(dbUser.AgeRestrictionIncludeUnknowns);
}
[Fact]
public async Task UserSync_CustomPrefix()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
const string prefix = "kavita-";
var claims = new List<Claim>()
{
new (ClaimTypes.Role, prefix + PolicyConstants.LoginRole),
new (ClaimTypes.Role, prefix + PolicyConstants.DownloadRole),
new (ClaimTypes.Role, PolicyConstants.PromoteRole),
new (ClaimTypes.Role, prefix + OidcService.AgeRestrictionPrefix + "M"),
new (ClaimTypes.Role, prefix + OidcService.LibraryAccessPrefix + "Manga"),
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
RolesPrefix = prefix,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
// Check correct roles assigned
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
Assert.Contains(PolicyConstants.LoginRole, userRoles);
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles);
// Check correct libraries
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
Assert.Single(libraries);
Assert.Contains(mangaLib.Name, libraries);
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
// Check correct age restrictions
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
Assert.False(dbUser.AgeRestrictionIncludeUnknowns);
}
[Fact]
public async Task SyncRoles()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var claims = new List<Claim>()
{
new (ClaimTypes.Role, PolicyConstants.LoginRole),
new (ClaimTypes.Role, PolicyConstants.DownloadRole),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
Assert.Contains(PolicyConstants.LoginRole, userRoles);
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
// Only give one role
claims = [new Claim(ClaimTypes.Role, PolicyConstants.LoginRole)];
identity = new ClaimsIdentity(claims);
principal = new ClaimsPrincipal(identity);
await oidcService.SyncUserSettings(null!, settings, principal, user);
userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
Assert.Contains(PolicyConstants.LoginRole, userRoles);
Assert.DoesNotContain(PolicyConstants.DownloadRole, userRoles);
}
[Fact]
public async Task SyncLibraries()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
UnitOfWork.LibraryRepository.Add(mangaLib);
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
await UnitOfWork.CommitAsync();
var claims = new List<Claim>()
{
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Manga"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
Assert.Single(libraries);
Assert.Contains(mangaLib.Name, libraries);
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
// Only give access to the other library
claims = [new Claim(ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels")];
identity = new ClaimsIdentity(claims);
principal = new ClaimsPrincipal(identity);
await oidcService.SyncUserSettings(null!, settings, principal, user);
// Check access has swicthed
libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
Assert.Single(libraries);
Assert.Contains(lightNovelsLib.Name, libraries);
Assert.DoesNotContain(mangaLib.Name, libraries);
}
[Fact]
public async Task SyncAgeRestrictions_NoRestrictions()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var claims = new List<Claim>()
{
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Not Applicable"),
new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
}
[Fact]
public async Task SyncAgeRestrictions_IncludeUnknowns()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var claims = new List<Claim>()
{
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
}
[Fact]
public async Task SyncAgeRestriction_AdminNone()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var claims = new List<Claim>()
{
new (ClaimTypes.Role, PolicyConstants.AdminRole),
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
}
[Fact]
public async Task SyncAgeRestriction_MultipleAgeRestrictionClaims()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var claims = new List<Claim>()
{
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"),
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
}
[Fact]
public async Task SyncAgeRestriction_NoAgeRestrictionClaims()
{
await ResetDb();
var (oidcService, user, _, _) = await Setup();
var identity = new ClaimsIdentity([]);
var principal = new ClaimsPrincipal(identity);
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
await oidcService.SyncUserSettings(null!, settings, principal, user);
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
// Also default to no restrictions when only include unknowns is present
identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns)]);
principal = new ClaimsPrincipal(identity);
await oidcService.SyncUserSettings(null!, settings, principal, user);
dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(dbUser);
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
}
[Fact]
public async Task SyncUserSettings_DontChangeDefaultAdmin()
{
await ResetDb();
var (oidcService, _, _, userManager) = await Setup();
// Make user default user
var user = await UnitOfWork.UserRepository.GetDefaultAdminUser();
var settings = new OidcConfigDto
{
SyncUserSettings = true,
};
var claims = new List<Claim>()
{
new (ClaimTypes.Role, PolicyConstants.ChangePasswordRole),
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
await oidcService.SyncUserSettings(null!, settings, principal, user);
var userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
Assert.NotNull(userFromDb);
Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction);
var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost").Build();
var res = await userManager.CreateAsync(newUser);
Assert.Empty(res.Errors);
Assert.True(res.Succeeded);
await oidcService.SyncUserSettings(null!, settings, principal, newUser);
userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(newUser.Id);
Assert.NotNull(userFromDb);
Assert.True(await userManager.IsInRoleAsync(newUser, PolicyConstants.ChangePasswordRole));
Assert.Equal(AgeRating.Teen, userFromDb.AgeRestriction);
}
[Fact]
public async Task FindBestAvailableName_NoDuplicates()
{
await ResetDb();
var (oidcService, _, _, userManager) = await Setup();
const string preferredName = "PreferredName";
const string name = "Name";
const string givenName = "GivenName";
const string surname = "Surname";
const string email = "Email";
var claims = new List<Claim>()
{
new(JwtRegisteredClaimNames.PreferredUsername, preferredName),
new(ClaimTypes.Name, name),
new(ClaimTypes.GivenName, givenName),
new(ClaimTypes.Surname, surname),
new(ClaimTypes.Email, email),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var bestName = await oidcService.FindBestAvailableName(principal);
Assert.NotNull(bestName);
Assert.Equal(preferredName, bestName);
// Create user with this name to make the method fallback to the next claim
var user = new AppUserBuilder(bestName, bestName).Build();
var res = await userManager.CreateAsync(user);
// This has actual information as to why it would fail, so we check it to make sure if the test fail here we know why
Assert.Empty(res.Errors);
Assert.True(res.Succeeded);
// Fallback to name
bestName = await oidcService.FindBestAvailableName(principal);
Assert.NotNull(bestName);
Assert.Equal(name, bestName);
user = new AppUserBuilder(bestName, bestName).Build();
res = await userManager.CreateAsync(user);
Assert.Empty(res.Errors);
Assert.True(res.Succeeded);
// Fallback to given name
bestName = await oidcService.FindBestAvailableName(principal);
Assert.NotNull(bestName);
Assert.Equal(givenName, bestName);
user = new AppUserBuilder(bestName, bestName).Build();
res = await userManager.CreateAsync(user);
Assert.Empty(res.Errors);
Assert.True(res.Succeeded);
// Fallback to surname
bestName = await oidcService.FindBestAvailableName(principal);
Assert.NotNull(bestName);
Assert.Equal(surname, bestName);
user = new AppUserBuilder(bestName, bestName).Build();
res = await userManager.CreateAsync(user);
Assert.Empty(res.Errors);
Assert.True(res.Succeeded);
// When none are found, returns null
bestName = await oidcService.FindBestAvailableName(principal);
Assert.Null(bestName);
}
private async Task<(OidcService, AppUser, IAccountService, UserManager<AppUser>)> Setup()
{
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
.WithRole(PolicyConstants.AdminRole)
.Build();
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
var roleStore = new RoleStore<
AppRole,
DataContext,
int,
IdentityUserRole<int>,
IdentityRoleClaim<int>
>(Context);
var roleManager = new RoleManager<AppRole>(
roleStore,
[new RoleValidator<AppRole>()],
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
Substitute.For<ILogger<RoleManager<AppRole>>>());
foreach (var role in PolicyConstants.ValidRoles)
{
if (!await roleManager.RoleExistsAsync(role))
{
await roleManager.CreateAsync(new AppRole
{
Name = role,
});
}
}
var userStore = new UserStore<
AppUser,
AppRole,
DataContext,
int,
IdentityUserClaim<int>,
AppUserRole,
IdentityUserLogin<int>,
IdentityUserToken<int>,
IdentityRoleClaim<int>
>(Context);
var userManager = new UserManager<AppUser>(userStore,
new OptionsWrapper<IdentityOptions>(new IdentityOptions()),
new PasswordHasher<AppUser>(),
[new UserValidator<AppUser>()],
[new PasswordValidator<AppUser>()],
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
Substitute.For<ILogger<UserManager<AppUser>>>());
// Create users with the UserManager such that the SecurityStamp is set
await userManager.CreateAsync(user);
await userManager.CreateAsync(defaultAdmin);
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), UnitOfWork, Mapper, Substitute.For<ILocalizationService>());
var oidcService = new OidcService(Substitute.For<ILogger<OidcService>>(), userManager, UnitOfWork, accountService, Substitute.For<IEmailService>());
return (oidcService, user, accountService, userManager);
}
protected override async Task ResetDb()
{
Context.AppUser.RemoveRange(Context.AppUser);
Context.Library.RemoveRange(Context.Library);
await UnitOfWork.CommitAsync();
}
}

View File

@ -34,7 +34,7 @@ public class SettingsServiceTests
_mockUnitOfWork = Substitute.For<IUnitOfWork>(); _mockUnitOfWork = Substitute.For<IUnitOfWork>();
_settingsService = new SettingsService(_mockUnitOfWork, ds, _settingsService = new SettingsService(_mockUnitOfWork, ds,
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
Substitute.For<ILogger<SettingsService>>()); Substitute.For<ILogger<SettingsService>>(), Substitute.For<IOidcService>());
} }
#region ImportMetadataSettings #region ImportMetadataSettings

View File

@ -10,6 +10,7 @@ using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Account; using API.DTOs.Account;
using API.DTOs.Email; using API.DTOs.Email;
using API.DTOs.Settings;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Errors; using API.Errors;
@ -52,6 +53,7 @@ public class AccountController : BaseApiController
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly IOidcService _oidcService;
/// <inheritdoc /> /// <inheritdoc />
public AccountController(UserManager<AppUser> userManager, public AccountController(UserManager<AppUser> userManager,
@ -60,7 +62,8 @@ public class AccountController : BaseApiController
ILogger<AccountController> logger, ILogger<AccountController> logger,
IMapper mapper, IAccountService accountService, IMapper mapper, IAccountService accountService,
IEmailService emailService, IEventHub eventHub, IEmailService emailService, IEventHub eventHub,
ILocalizationService localizationService) ILocalizationService localizationService,
IOidcService oidcService)
{ {
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
@ -72,6 +75,50 @@ public class AccountController : BaseApiController
_emailService = emailService; _emailService = emailService;
_eventHub = eventHub; _eventHub = eventHub;
_localizationService = localizationService; _localizationService = localizationService;
_oidcService = oidcService;
}
/// <summary>
/// Returns true if OIDC authentication cookies are present
/// </summary>
/// <remarks>Makes not guarantee about their validity</remarks>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("oidc-authenticated")]
public ActionResult<bool> OidcAuthenticated()
{
return HttpContext.Request.Cookies.ContainsKey(OidcService.CookieName);
}
/// <summary>
/// Returns the current user, as it would from login
/// </summary>
/// <returns></returns>
/// <exception cref="UnauthorizedAccessException"></exception>
/// <remarks>Does not return tokens for the user</remarks>
/// <remarks>Updates the last active date for the user</remarks>
[HttpGet]
public async Task<ActionResult<UserDto>> GetCurrentUserAsync()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user == null) throw new UnauthorizedAccessException();
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
try
{
user.UpdateLastActive();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return Ok(await ConstructUserDto(user, roles, false));
} }
/// <summary> /// <summary>
@ -151,10 +198,10 @@ public class AccountController : BaseApiController
if (!result.Succeeded) return BadRequest(result.Errors); if (!result.Succeeded) return BadRequest(result.Errors);
// Assign default streams // Assign default streams
AddDefaultStreamsToUser(user); _accountService.AddDefaultStreamsToUser(user);
// Assign default reading profile // Assign default reading profile
await AddDefaultReadingProfileToUser(user); await _accountService.AddDefaultReadingProfileToUser(user);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen")); if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
@ -224,6 +271,11 @@ public class AccountController : BaseApiController
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
var oidcConfig = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
// Setting only takes effect if OIDC is functional, and if we're not logging in via ApiKey
var disablePasswordAuthentication = oidcConfig is {Enabled: true, DisablePasswordAuthentication: true} && string.IsNullOrEmpty(loginDto.ApiKey);
if (disablePasswordAuthentication && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "password-authentication-disabled"));
if (string.IsNullOrEmpty(loginDto.ApiKey)) if (string.IsNullOrEmpty(loginDto.ApiKey))
{ {
var result = await _signInManager var result = await _signInManager
@ -249,7 +301,14 @@ public class AccountController : BaseApiController
} }
// Update LastActive on account // Update LastActive on account
user.UpdateLastActive(); try
{
user.UpdateLastActive();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
// NOTE: This can likely be removed // NOTE: This can likely be removed
user.UserPreferences ??= new AppUserPreferences user.UserPreferences ??= new AppUserPreferences
@ -262,18 +321,28 @@ public class AccountController : BaseApiController
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
return Ok(await ConstructUserDto(user, roles));
}
private async Task<UserDto> ConstructUserDto(AppUser user, IList<string> roles, bool includeTokens = true)
{
var dto = _mapper.Map<UserDto>(user); var dto = _mapper.Map<UserDto>(user);
dto.Token = await _tokenService.CreateToken(user);
dto.RefreshToken = await _tokenService.CreateRefreshToken(user); if (includeTokens)
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)) {
.Value; dto.Token = await _tokenService.CreateToken(user);
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
}
dto.Roles = roles;
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value;
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName!); 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(); pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref); dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
return dto;
return Ok(dto);
} }
/// <summary> /// <summary>
@ -286,13 +355,9 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences);
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var dto = _mapper.Map<UserDto>(user); var roles = await _userManager.GetRolesAsync(user);
dto.Token = await _tokenService.CreateToken(user);
dto.RefreshToken = await _tokenService.CreateRefreshToken(user); return Ok(await ConstructUserDto(user, roles, !HttpContext.Request.Cookies.ContainsKey(OidcService.CookieName)));
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion))
.Value;
dto.Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences);
return Ok(dto);
} }
/// <summary> /// <summary>
@ -505,6 +570,7 @@ public class AccountController : BaseApiController
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <remarks>Users who's <see cref="AppUser.IdentityProvider"/> is not <see cref="IdentityProvider.Kavita"/> cannot be edited if <see cref="OidcConfigDto.SyncUserSettings"/> is true</remarks>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")] [HttpPost("update")]
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto) public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
@ -517,6 +583,16 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
try
{
if (await _accountService.ChangeIdentityProvider(User.GetUserId(), user, dto.IdentityProvider)) return Ok();
}
catch (KavitaException exception)
{
return BadRequest(exception.Message);
}
// Check if username is changing // Check if username is changing
if (!user.UserName!.Equals(dto.Username)) if (!user.UserName!.Equals(dto.Username))
{ {
@ -670,10 +746,10 @@ public class AccountController : BaseApiController
if (!result.Succeeded) return BadRequest(result.Errors); if (!result.Succeeded) return BadRequest(result.Errors);
// Assign default streams // Assign default streams
AddDefaultStreamsToUser(user); _accountService.AddDefaultStreamsToUser(user);
// Assign default reading profile // Assign default reading profile
await AddDefaultReadingProfileToUser(user); await _accountService.AddDefaultReadingProfileToUser(user);
// Assign Roles // Assign Roles
var roles = dto.Roles; var roles = dto.Roles;
@ -772,29 +848,6 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user"));
} }
private void AddDefaultStreamsToUser(AppUser user)
{
foreach (var newStream in Seed.DefaultStreams.Select(stream => _mapper.Map<AppUserDashboardStream, AppUserDashboardStream>(stream)))
{
user.DashboardStreams.Add(newStream);
}
foreach (var stream in Seed.DefaultSideNavStreams.Select(stream => _mapper.Map<AppUserSideNavStream, AppUserSideNavStream>(stream)))
{
user.SideNavStreams.Add(stream);
}
}
private async Task AddDefaultReadingProfileToUser(AppUser user)
{
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Default Profile")
.WithKind(ReadingProfileKind.Default)
.Build();
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
await _unitOfWork.CommitAsync();
}
/// <summary> /// <summary>
/// Last step in authentication flow, confirms the email token for email /// Last step in authentication flow, confirms the email token for email
/// </summary> /// </summary>

View File

@ -0,0 +1,37 @@
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
[Route("[controller]")]
public class OidcController: ControllerBase
{
[AllowAnonymous]
[HttpGet("login")]
public IActionResult Login(string returnUrl = "/")
{
var properties = new AuthenticationProperties { RedirectUri = returnUrl };
return Challenge(properties, IdentityServiceExtensions.OpenIdConnect);
}
[HttpGet("logout")]
public IActionResult Logout()
{
if (!Request.Cookies.ContainsKey(OidcService.CookieName))
{
return Redirect("/");
}
return SignOut(
new AuthenticationProperties { RedirectUri = "/login" },
CookieAuthenticationDefaults.AuthenticationScheme,
IdentityServiceExtensions.OpenIdConnect);
}
}

View File

@ -274,4 +274,35 @@ public class SettingsController : BaseApiController
} }
} }
/// <summary>
/// Retrieve publicly required configuration regarding Oidc
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("oidc")]
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
{
var settings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var publicConfig = _mapper.Map<OidcPublicConfigDto>(settings);
publicConfig.Enabled = !string.IsNullOrEmpty(settings.Authority) &&
!string.IsNullOrEmpty(settings.ClientId) &&
!string.IsNullOrEmpty(settings.Secret);
return Ok(publicConfig);
}
/// <summary>
/// Validate if the given authority is reachable from the server
/// </summary>
/// <param name="authority"></param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("is-valid-authority")]
public async Task<ActionResult<bool>> IsValidAuthority([FromBody] AuthorityValidationDto authority)
{
return Ok(await _settingsService.IsValidAuthority(authority.Authority));
}
} }

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using API.Entities.Enums;
namespace API.DTOs.Account; namespace API.DTOs.Account;
#nullable enable #nullable enable
@ -25,4 +26,5 @@ public sealed record UpdateUserDto
public AgeRestrictionDto AgeRestriction { get; init; } = default!; public AgeRestrictionDto AgeRestriction { get; init; } = default!;
/// <inheritdoc cref="API.Entities.AppUser.Email"/> /// <inheritdoc cref="API.Entities.AppUser.Email"/>
public string? Email { get; set; } = default!; public string? Email { get; set; } = default!;
public IdentityProvider IdentityProvider { get; init; } = IdentityProvider.Kavita;
} }

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Account; using API.DTOs.Account;
using API.Entities.Enums;
namespace API.DTOs; namespace API.DTOs;
#nullable enable #nullable enable
@ -24,4 +25,5 @@ public sealed record MemberDto
public DateTime LastActiveUtc { get; init; } public DateTime LastActiveUtc { get; init; }
public IEnumerable<LibraryDto>? Libraries { get; init; } public IEnumerable<LibraryDto>? Libraries { get; init; }
public IEnumerable<string>? Roles { get; init; } public IEnumerable<string>? Roles { get; init; }
public IdentityProvider IdentityProvider { get; init; }
} }

View File

@ -0,0 +1,3 @@
namespace API.DTOs.Settings;
public sealed record AuthorityValidationDto(string Authority);

View File

@ -0,0 +1,68 @@
#nullable enable
using System.Collections.Generic;
using System.Security.Claims;
using API.Entities.Enums;
namespace API.DTOs.Settings;
/// <summary>
/// All configuration regarding OIDC
/// </summary>
/// <remarks>This class is saved as a JsonObject in the DB, assign default values to prevent unexpected NPE</remarks>
public sealed record OidcConfigDto: OidcPublicConfigDto
{
/// <summary>
/// Optional OpenID Connect Authority URL. Not managed in DB. Managed in appsettings.json and synced to DB.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// Optional OpenID Connect ClientId, defaults to kavita. Not managed in DB. Managed in appsettings.json and synced to DB.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Optional OpenID Connect Secret. Not managed in DB. Managed in appsettings.json and synced to DB.
/// </summary>
public string Secret { get; set; } = string.Empty;
/// <summary>
/// If true, auto creates a new account when someone logs in via OpenID Connect
/// </summary>
public bool ProvisionAccounts { get; set; } = false;
/// <summary>
/// Require emails to be verified by the OpenID Connect provider when creating accounts on login
/// </summary>
public bool RequireVerifiedEmail { get; set; } = true;
/// <summary>
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provided roles on log in.
/// </summary>
public bool SyncUserSettings { get; set; } = false;
/// <summary>
/// A prefix that all roles Kavita checks for during sync must have
/// </summary>
public string RolesPrefix { get; set; } = string.Empty;
/// <summary>
/// The JWT claim roles are mapped under, defaults to <see cref="ClaimTypes.Role"/>
/// </summary>
public string RolesClaim { get; set; } = ClaimTypes.Role;
/// <summary>
/// Custom scopes Kavita should request from your OIDC provider
/// </summary>
/// <remarks>Advanced setting</remarks>
public List<string> CustomScopes { get; set; } = [];
// Default values used when SyncUserSettings is false
#region Default user settings
public List<string> DefaultRoles { get; set; } = [];
public List<int> DefaultLibraries { get; set; } = [];
public AgeRating DefaultAgeRestriction { get; set; } = AgeRating.Unknown;
public bool DefaultIncludeUnknowns { get; set; } = false;
#endregion
/// <summary>
/// Returns true if the <see cref="OidcPublicConfigDto.Authority"/> has been set
/// </summary>
public bool Enabled => !string.IsNullOrEmpty(Authority);
}

View File

@ -0,0 +1,24 @@
#nullable enable
namespace API.DTOs.Settings;
/**
* The part of the OIDC configuration that is returned by the API without authentication
*/
public record OidcPublicConfigDto
{
/// <summary>
/// Automatically redirect to the Oidc login screen
/// </summary>
public bool AutoLogin { get; set; }
/// <summary>
/// Disables password authentication for non-admin users
/// </summary>
public bool DisablePasswordAuthentication { get; set; }
/// <summary>
/// Name of your provider, used to display on the login screen
/// </summary>
/// <remarks>Default to OpenID Connect</remarks>
public string ProviderName { get; set; } = "OpenID Connect";
public bool Enabled { get; set; } = false;
}

View File

@ -92,6 +92,11 @@ public sealed record ServerSettingDto
/// SMTP Configuration /// SMTP Configuration
/// </summary> /// </summary>
public SmtpConfigDto SmtpConfig { get; set; } public SmtpConfigDto SmtpConfig { get; set; }
/// <summary>
/// OIDC Configuration
/// </summary>
public OidcConfigDto OidcConfig { get; set; }
/// <summary> /// <summary>
/// The Date Kavita was first installed /// The Date Kavita was first installed
/// </summary> /// </summary>

View File

@ -22,6 +22,10 @@ public sealed record LibraryStatV3
/// </summary> /// </summary>
public bool CreateReadingListsFromMetadata { get; set; } public bool CreateReadingListsFromMetadata { get; set; }
/// <summary> /// <summary>
/// If the library has metadata turned on
/// </summary>
public bool EnabledMetadata { get; set; }
/// <summary>
/// Type of the Library /// Type of the Library
/// </summary> /// </summary>
public LibraryType LibraryType { get; set; } public LibraryType LibraryType { get; set; }

View File

@ -131,6 +131,10 @@ public sealed record ServerInfoV3Dto
/// Is this server using Kavita+ /// Is this server using Kavita+
/// </summary> /// </summary>
public bool ActiveKavitaPlusSubscription { get; set; } public bool ActiveKavitaPlusSubscription { get; set; }
/// <summary>
/// Is OIDC enabled
/// </summary>
public bool OidcEnabled { get; set; }
#endregion #endregion
#region Users #region Users

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using API.Data.Misc; using API.Data.Misc;
using API.Entities.Enums;
using API.Entities.Enums.Device; using API.Entities.Enums.Device;
namespace API.DTOs.Stats.V3; namespace API.DTOs.Stats.V3;
@ -76,6 +77,10 @@ public sealed record UserStatV3
/// Roles for this user /// Roles for this user
/// </summary> /// </summary>
public ICollection<string> Roles { get; set; } public ICollection<string> Roles { get; set; }
/// <summary>
/// Who manages the user (OIDC, Kavita)
/// </summary>
public IdentityProvider IdentityProvider { get; set; }
} }

View File

@ -1,6 +1,8 @@
 
using System; using System.Collections.Generic;
using API.DTOs.Account; using API.DTOs.Account;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs; namespace API.DTOs;
#nullable enable #nullable enable
@ -9,10 +11,13 @@ public sealed record UserDto
{ {
public string Username { get; init; } = null!; public string Username { get; init; } = null!;
public string Email { get; init; } = null!; public string Email { get; init; } = null!;
public IList<string> Roles { get; set; } = [];
public string Token { get; set; } = null!; public string Token { get; set; } = null!;
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public string? ApiKey { get; init; } public string? ApiKey { get; init; }
public UserPreferencesDto? Preferences { get; set; } public UserPreferencesDto? Preferences { get; set; }
public AgeRestrictionDto? AgeRestriction { get; init; } public AgeRestrictionDto? AgeRestriction { get; init; }
public string KavitaVersion { get; set; } public string KavitaVersion { get; set; }
/// <inheritdoc cref="AppUser.IdentityProvider"/>
public IdentityProvider IdentityProvider { get; init; }
} }

View File

@ -300,6 +300,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
v => JsonSerializer.Deserialize<IList<MetadataSettingField>>(v, JsonSerializerOptions.Default) ?? new List<MetadataSettingField>()) v => JsonSerializer.Deserialize<IList<MetadataSettingField>>(v, JsonSerializerOptions.Default) ?? new List<MetadataSettingField>())
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>()); .HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<AppUser>()
.Property(user => user.IdentityProvider)
.HasDefaultValue(IdentityProvider.Kavita);
} }
#nullable enable #nullable enable

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class OpenIDConnect : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "IdentityProvider",
table: "AspNetUsers",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "OidcId",
table: "AspNetUsers",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IdentityProvider",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "OidcId",
table: "AspNetUsers");
}
}
}

View File

@ -90,6 +90,11 @@ namespace API.Data.Migrations
b.Property<bool>("HasRunScrobbleEventGeneration") b.Property<bool>("HasRunScrobbleEventGeneration")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("IdentityProvider")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<DateTime>("LastActive") b.Property<DateTime>("LastActive")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -116,6 +121,9 @@ namespace API.Data.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("OidcId")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -3640,7 +3648,8 @@ namespace API.Data.Migrations
b.Navigation("TableOfContents"); b.Navigation("TableOfContents");
b.Navigation("UserPreferences"); b.Navigation("UserPreferences")
.IsRequired();
b.Navigation("UserRoles"); b.Navigation("UserRoles");

View File

@ -107,6 +107,13 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds); Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo(); Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail); Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
/// <summary>
/// Try getting a user by the id provided by OIDC
/// </summary>
/// <param name="oidcId"></param>
/// <param name="includes"></param>
/// <returns></returns>
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None);
} }
public class UserRepository : IUserRepository public class UserRepository : IUserRepository
@ -557,6 +564,16 @@ public class UserRepository : IUserRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None)
{
if (string.IsNullOrEmpty(oidcId)) return null;
return await _context.AppUser
.Where(u => u.OidcId == oidcId)
.Includes(includes)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync() public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{ {
@ -789,6 +806,7 @@ public class UserRepository : IUserRepository
LastActiveUtc = u.LastActiveUtc, LastActiveUtc = u.LastActiveUtc,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
IsPending = !u.EmailConfirmed, IsPending = !u.EmailConfirmed,
IdentityProvider = u.IdentityProvider,
AgeRestriction = new AgeRestrictionDto() AgeRestriction = new AgeRestrictionDto()
{ {
AgeRating = u.AgeRestriction, AgeRating = u.AgeRestriction,
@ -800,7 +818,7 @@ public class UserRepository : IUserRepository
Type = l.Type, Type = l.Type,
LastScanned = l.LastScanned, LastScanned = l.LastScanned,
Folders = l.Folders.Select(x => x.Path).ToList() Folders = l.Folders.Select(x => x.Path).ToList()
}).ToList() }).ToList(),
}) })
.AsSplitQuery() .AsSplitQuery()
.AsNoTracking() .AsNoTracking()

View File

@ -5,9 +5,11 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs.Settings;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Enums.Theme; using API.Entities.Enums.Theme;
@ -252,6 +254,7 @@ public static class Seed
new() { new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json }, // Not used from DB, but DB is sync with appSettings.json
new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())},
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
@ -289,9 +292,29 @@ public static class Seed
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value =
Configuration.CacheSize + string.Empty; Configuration.CacheSize + string.Empty;
await SetOidcSettingsFromDisk(context);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public static async Task SetOidcSettingsFromDisk(DataContext context)
{
var oidcSettingEntry = await context.ServerSetting
.FirstOrDefaultAsync(setting => setting.Key == ServerSettingKey.OidcConfiguration);
var storedOidcSettings = JsonSerializer.Deserialize<OidcConfigDto>(oidcSettingEntry!.Value)!;
var diskOidcSettings = Configuration.OidcSettings;
storedOidcSettings.Authority = diskOidcSettings.Authority;
storedOidcSettings.ClientId = diskOidcSettings.ClientId;
storedOidcSettings.Secret = diskOidcSettings.Secret;
storedOidcSettings.CustomScopes = diskOidcSettings.CustomScopes;
oidcSettingEntry.Value = JsonSerializer.Serialize(storedOidcSettings);
}
public static async Task SeedMetadataSettings(DataContext context) public static async Task SeedMetadataSettings(DataContext context)
{ {
await context.Database.EnsureCreatedAsync(); await context.Database.EnsureCreatedAsync();

View File

@ -1,6 +1,8 @@
using System; #nullable enable
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using API.DTOs.Settings;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Interfaces; using API.Entities.Interfaces;
using API.Entities.Scrobble; using API.Entities.Scrobble;
@ -89,6 +91,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// <remarks>Kavita+ only</remarks> /// <remarks>Kavita+ only</remarks>
public DateTime ScrobbleEventGenerationRan { get; set; } public DateTime ScrobbleEventGenerationRan { get; set; }
/// <summary>
/// The sub returned the by OIDC provider
/// </summary>
public string? OidcId { get; set; }
/// <summary>
/// The IdentityProvider for the user, default to <see cref="Enums.IdentityProvider.Kavita"/>
/// </summary>
public IdentityProvider IdentityProvider { get; set; } = IdentityProvider.Kavita;
/// <summary> /// <summary>
/// A list of Series the user doesn't want scrobbling for /// A list of Series the user doesn't want scrobbling for

View File

@ -0,0 +1,14 @@
using System.ComponentModel;
namespace API.Entities.Enums;
/// <summary>
/// Who provides the identity of the user
/// </summary>
public enum IdentityProvider
{
[Description("Kavita")]
Kavita = 0,
[Description("OpenID Connect")]
OpenIdConnect = 1,
}

View File

@ -197,4 +197,10 @@ public enum ServerSettingKey
/// </summary> /// </summary>
[Description("FirstInstallVersion")] [Description("FirstInstallVersion")]
FirstInstallVersion = 39, FirstInstallVersion = 39,
/// <summary>
/// A Json object of type <see cref="API.DTOs.Settings.OidcConfigDto"/>
/// </summary>
[Description("OidcConfiguration")]
OidcConfiguration = 40,
} }

View File

@ -4,12 +4,14 @@ using API.Data;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Store;
using API.Services.Tasks; using API.Services.Tasks;
using API.Services.Tasks.Metadata; using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner;
using API.SignalR; using API.SignalR;
using API.SignalR.Presence; using API.SignalR.Presence;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
@ -83,6 +85,8 @@ public static class ApplicationServiceExtensions
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>(); services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
services.AddScoped<IWantToReadSyncService, WantToReadSyncService>(); services.AddScoped<IWantToReadSyncService, WantToReadSyncService>();
services.AddScoped<IOidcService, OidcService>();
services.AddSqLite(); services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true); services.AddSignalR(opt => opt.EnableDetailedErrors = true);
@ -106,6 +110,7 @@ public static class ApplicationServiceExtensions
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB
options.CompactionPercentage = 0.1; // LRU compaction (10%) options.CompactionPercentage = 0.1; // LRU compaction (10%)
}); });
services.AddSingleton<ITicketStore, CustomTicketStore>();
services.AddSwaggerGen(g => services.AddSwaggerGen(g =>
{ {

View File

@ -1,4 +1,7 @@
using System.Security.Claims; using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using API.Constants;
using Kavita.Common; using Kavita.Common;
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;
@ -8,6 +11,8 @@ namespace API.Extensions;
public static class ClaimsPrincipalExtensions public static class ClaimsPrincipalExtensions
{ {
private const string NotAuthenticatedMessage = "User is not authenticated"; private const string NotAuthenticatedMessage = "User is not authenticated";
private const string EmailVerifiedClaimType = "email_verified";
/// <summary> /// <summary>
/// Get's the authenticated user's username /// Get's the authenticated user's username
/// </summary> /// </summary>
@ -26,4 +31,26 @@ public static class ClaimsPrincipalExtensions
var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage); var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage);
return int.Parse(userClaim.Value); return int.Parse(userClaim.Value);
} }
public static bool HasVerifiedEmail(this ClaimsPrincipal user)
{
var emailVerified = user.FindFirst(EmailVerifiedClaimType);
if (emailVerified == null) return false;
if (!bool.TryParse(emailVerified.Value, out bool emailVerifiedValue) || !emailVerifiedValue)
{
return false;
}
return true;
}
public static IList<string> GetClaimsWithPrefix(this ClaimsPrincipal claimsPrincipal, string claimType, string prefix)
{
return claimsPrincipal
.FindAll(claimType)
.Where(c => c.Value.StartsWith(prefix))
.Select(c => c.Value.TrimPrefix(prefix))
.ToList();
}
} }

View File

@ -0,0 +1,43 @@
#nullable enable
using System;
using System.ComponentModel;
using System.Reflection;
namespace API.Extensions;
public static class EnumExtensions
{
/// <summary>
/// Extension on Enum.TryParse which also tried matching on the description attribute
/// </summary>
/// <returns>if a match was found</returns>
/// <remarks>First tries Enum.TryParse then fall back to the more expensive operation</remarks>
public static bool TryParse<TEnum>(string? value, out TEnum result) where TEnum : struct, Enum
{
result = default;
if (string.IsNullOrEmpty(value))
{
return false;
}
if (Enum.TryParse(value, out result))
{
return true;
}
foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static))
{
var description = field.GetCustomAttribute<DescriptionAttribute>()?.Description;
if (!string.IsNullOrEmpty(description) &&
string.Equals(description, value, StringComparison.OrdinalIgnoreCase))
{
result = (TEnum)field.GetValue(null)!;
return true;
}
}
return false;
}
}

View File

@ -6,6 +6,7 @@ using API.Data.Misc;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
using Microsoft.AspNetCore.Identity;
namespace API.Extensions; namespace API.Extensions;
#nullable enable #nullable enable

View File

@ -1,21 +1,43 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Entities; using API.Entities;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext;
namespace API.Extensions; namespace API.Extensions;
#nullable enable #nullable enable
public static class IdentityServiceExtensions public static class IdentityServiceExtensions
{ {
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) private const string DynamicHybrid = nameof(DynamicHybrid);
public const string OpenIdConnect = nameof(OpenIdConnect);
private const string LocalIdentity = nameof(LocalIdentity);
private const string OidcCallback = "/signin-oidc";
private const string OidcLogoutCallback = "/signout-callback-oidc";
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment environment)
{ {
services.Configure<IdentityOptions>(options => services.Configure<IdentityOptions>(options =>
{ {
@ -47,42 +69,264 @@ public static class IdentityServiceExtensions
.AddRoleValidator<RoleValidator<AppRole>>() .AddRoleValidator<RoleValidator<AppRole>>()
.AddEntityFrameworkStores<DataContext>(); .AddEntityFrameworkStores<DataContext>();
var oidcSettings = Configuration.OidcSettings;
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) var auth = services.AddAuthentication(DynamicHybrid)
.AddJwtBearer(options => .AddPolicyScheme(DynamicHybrid, JwtBearerDefaults.AuthenticationScheme, options =>
{ {
options.TokenValidationParameters = new TokenValidationParameters() var enabled = oidcSettings.Enabled;
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita"
};
options.Events = new JwtBearerEvents() options.ForwardDefaultSelector = ctx =>
{ {
OnMessageReceived = context => if (!enabled) return LocalIdentity;
if (ctx.Request.Path.StartsWithSegments(OidcCallback) ||
ctx.Request.Path.StartsWithSegments(OidcLogoutCallback))
{ {
var accessToken = context.Request.Query["access_token"]; return OpenIdConnect;
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;
} }
if (ctx.Request.Headers.Authorization.Count != 0)
{
return LocalIdentity;
}
if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName))
{
return OpenIdConnect;
}
return LocalIdentity;
}; };
}); });
services.AddAuthorization(opt =>
if (oidcSettings.Enabled)
{ {
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); services.SetupOpenIdConnectAuthentication(auth, oidcSettings, environment);
opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); }
opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
auth.AddJwtBearer(LocalIdentity, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita",
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery,
};
}); });
services.AddAuthorizationBuilder()
.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole))
.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole))
.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
return services; return services;
} }
private static void SetupOpenIdConnectAuthentication(this IServiceCollection services, AuthenticationBuilder auth,
Configuration.OpenIdConnectSettings settings, IWebHostEnvironment environment)
{
var isDevelopment = environment.IsEnvironment(Environments.Development);
var baseUrl = Configuration.BaseUrl;
var apiPrefix = baseUrl + "api";
var hubsPrefix = baseUrl + "hubs";
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme).Configure<ITicketStore>((options, store) =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.MaxAge = TimeSpan.FromDays(7);
options.SessionStore = store;
if (isDevelopment)
{
options.Cookie.Domain = null;
}
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async ctx =>
{
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
var user = await oidcService.RefreshCookieToken(ctx);
if (user != null)
{
var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user!, false);
ctx.ReplacePrincipal(new ClaimsPrincipal(new ClaimsIdentity(claims, ctx.Scheme.Name)));
}
},
OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
},
};
});
auth.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
auth.AddOpenIdConnect(OpenIdConnect, options =>
{
options.Authority = settings.Authority;
options.ClientId = settings.ClientId;
options.ClientSecret = settings.Secret;
options.RequireHttpsMetadata = options.Authority.StartsWith("https://");
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ResponseType = OpenIdConnectResponseType.Code;
options.CallbackPath = OidcCallback;
options.SignedOutCallbackPath = OidcLogoutCallback;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("offline_access");
options.Scope.Add("roles");
options.Scope.Add("email");
foreach (var customScope in settings.CustomScopes)
{
options.Scope.Add(customScope);
}
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = OidcClaimsPrincipalConverter,
OnAuthenticationFailed = ctx =>
{
ctx.Response.Redirect(baseUrl + "login?skipAutoLogin=true&error=" + Uri.EscapeDataString(ctx.Exception.Message));
ctx.HandleResponse();
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = ctx =>
{
if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.PostLogoutRedirectUri))
{
ctx.ProtocolMessage.PostLogoutRedirectUri = ctx.ProtocolMessage.PostLogoutRedirectUri.Replace("http://", "https://");
}
return Task.CompletedTask;
},
OnRedirectToIdentityProvider = ctx =>
{
// Intercept redirects on API requests and instead return 401
// These redirects are auto login when .NET finds a cookie that it can't match inside the cookie store. I.e. after a restart
if (ctx.Request.Path.StartsWithSegments(apiPrefix) || ctx.Request.Path.StartsWithSegments(hubsPrefix))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
ctx.HandleResponse();
return Task.CompletedTask;
}
if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.RedirectUri))
{
ctx.ProtocolMessage.RedirectUri = ctx.ProtocolMessage.RedirectUri.Replace("http://", "https://");
}
return Task.CompletedTask;
},
};
});
}
/// <summary>
/// Called after the redirect from the OIDC provider, tries matching the user and update the principal
/// to have the correct claims and properties. This is required to later auto refresh; and ensure .NET knows which
/// Kavita roles the user has
/// </summary>
/// <param name="ctx"></param>
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
{
if (ctx.Principal == null) return;
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
var user = await oidcService.LoginOrCreate(ctx.Request, ctx.Principal);
if (user == null)
{
throw new KavitaException("errors.oidc.no-account");
}
var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user);
var tokens = CopyOidcTokens(ctx);
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
ctx.Properties ??= new AuthenticationProperties();
ctx.Properties.StoreTokens(tokens);
ctx.HttpContext.User = principal;
ctx.Principal = principal;
ctx.Success();
}
/// <summary>
/// Copy tokens returned by the OIDC provider that we require later
/// </summary>
/// <param name="ctx"></param>
/// <returns></returns>
private static List<AuthenticationToken> CopyOidcTokens(TokenValidatedContext ctx)
{
if (ctx.TokenEndpointResponse == null)
{
return [];
}
var tokens = new List<AuthenticationToken>();
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken))
{
tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken });
}
else
{
var logger = ctx.HttpContext.RequestServices.GetRequiredService<ILogger<OidcService>>();
logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user");
}
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken))
{
tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken });
}
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn))
{
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn));
tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") });
}
return tokens;
}
private static Task SetTokenFromQuery(MessageReceivedContext context)
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Only use query string based token on SignalR hubs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
} }

View File

@ -52,4 +52,33 @@ public static class StringExtensions
{ {
return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture); return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture);
} }
public static string TrimPrefix(this string? value, string prefix)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (!value.StartsWith(prefix)) return value;
return value.Substring(prefix.Length);
}
/// <summary>
/// Censor the input string by removing all but the first and last char.
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
/// <remarks>If the input is an email (contains @), the domain will remain untouched</remarks>
public static string Censor(this string? input)
{
if (string.IsNullOrWhiteSpace(input)) return input ?? string.Empty;
var atIdx = input.IndexOf('@');
if (atIdx == -1)
{
return $"{input[0]}{new string('*', input.Length - 1)}";
}
return input[0] + new string('*', atIdx - 1) + input[atIdx..];
}
} }

View File

@ -386,7 +386,6 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>())) .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>())); .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
} }
} }

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Text.Json;
using API.DTOs.Settings; using API.DTOs.Settings;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -129,6 +130,9 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.FirstInstallVersion: case ServerSettingKey.FirstInstallVersion:
destination.FirstInstallVersion = row.Value; destination.FirstInstallVersion = row.Value;
break; break;
case ServerSettingKey.OidcConfiguration:
destination.OidcConfig = JsonSerializer.Deserialize<OidcConfigDto>(row.Value)!;
break;
case ServerSettingKey.LicenseKey: case ServerSettingKey.LicenseKey:
case ServerSettingKey.EnableAuthentication: case ServerSettingKey.EnableAuthentication:
case ServerSettingKey.EmailServiceUrl: case ServerSettingKey.EmailServiceUrl:

View File

@ -2,6 +2,7 @@
"confirm-email": "You must confirm your email first", "confirm-email": "You must confirm your email first",
"locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.", "locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.",
"disabled-account": "Your account is disabled. Contact the server admin.", "disabled-account": "Your account is disabled. Contact the server admin.",
"password-authentication-disabled": "Password authentication has been disabled, login via OpenID Connect",
"register-user": "Something went wrong when registering user", "register-user": "Something went wrong when registering user",
"validate-email": "There was an issue validating your email: {0}", "validate-email": "There was an issue validating your email: {0}",
"confirm-token-gen": "There was an issue generating a confirmation token", "confirm-token-gen": "There was an issue generating a confirmation token",
@ -17,6 +18,8 @@
"generate-token": "There was an issue generating a confirmation email token. See logs", "generate-token": "There was an issue generating a confirmation email token. See logs",
"age-restriction-update": "There was an error updating the age restriction", "age-restriction-update": "There was an error updating the age restriction",
"no-user": "User does not exist", "no-user": "User does not exist",
"oidc-managed": "Users managed by OIDC cannot be edited.",
"cannot-change-identity-provider-original-user": "Identity Provider of the original admin account cannot be changed",
"username-taken": "Username already taken", "username-taken": "Username already taken",
"email-taken": "Email already in use", "email-taken": "Email already in use",
"user-already-confirmed": "User is already confirmed", "user-already-confirmed": "User is already confirmed",
@ -42,6 +45,7 @@
"email-not-enabled": "Email is not enabled on this server. You cannot perform this action.", "email-not-enabled": "Email is not enabled on this server. You cannot perform this action.",
"account-email-invalid": "The email on file for the admin account is not a valid email. Cannot send test email.", "account-email-invalid": "The email on file for the admin account is not a valid email. Cannot send test email.",
"email-settings-invalid": "Email settings missing information. Ensure all email settings are saved.", "email-settings-invalid": "Email settings missing information. Ensure all email settings are saved.",
"oidc-invalid-authority": "OIDC authority is invalid",
"chapter-doesnt-exist": "Chapter does not exist", "chapter-doesnt-exist": "Chapter does not exist",
"file-missing": "File was not found in book", "file-missing": "File was not found in book",

View File

@ -1,19 +1,22 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.DTOs.Account; using API.DTOs.Account;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Errors; using API.Errors;
using API.Extensions; using API.Extensions;
using API.Helpers.Builders;
using API.SignalR;
using AutoMapper;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services; namespace API.Services;
@ -24,25 +27,56 @@ public interface IAccountService
{ {
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword); Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password); Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password);
Task<IEnumerable<ApiException>> ValidateUsername(string username); Task<IEnumerable<ApiException>> ValidateUsername(string? username);
Task<IEnumerable<ApiException>> ValidateEmail(string email); Task<IEnumerable<ApiException>> ValidateEmail(string email);
Task<bool> HasBookmarkPermission(AppUser? user); Task<bool> HasBookmarkPermission(AppUser? user);
Task<bool> HasDownloadPermission(AppUser? user); Task<bool> HasDownloadPermission(AppUser? user);
Task<bool> CanChangeAgeRestriction(AppUser? user); Task<bool> CanChangeAgeRestriction(AppUser? user);
/// <summary>
///
/// </summary>
/// <param name="actingUserId">The user who is changing the identity</param>
/// <param name="user">the user being changed</param>
/// <param name="identityProvider"> the provider being changed to</param>
/// <returns>If true, user should not be updated by kavita (anymore)</returns>
/// <exception cref="KavitaException">Throws if invalid actions are being performed</exception>
Task<bool> ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider);
/// <summary>
/// Removes access to all libraries, then grant access to all given libraries or all libraries if the user is admin.
/// Creates side nav streams as well
/// </summary>
/// <param name="user"></param>
/// <param name="librariesIds"></param>
/// <param name="hasAdminRole"></param>
/// <returns></returns>
/// <remarks>Ensure that the users SideNavStreams are loaded</remarks>
/// <remarks>Does NOT commit</remarks>
Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole);
Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles);
void AddDefaultStreamsToUser(AppUser user);
Task AddDefaultReadingProfileToUser(AppUser user);
} }
public class AccountService : IAccountService public partial class AccountService : IAccountService
{ {
private readonly ILocalizationService _localizationService;
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly ILogger<AccountService> _logger; private readonly ILogger<AccountService> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
public static readonly Regex AllowedUsernameRegex = AllowedUsernameRegexAttr();
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork)
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork,
IMapper mapper, ILocalizationService localizationService)
{ {
_localizationService = localizationService;
_userManager = userManager; _userManager = userManager;
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper;
} }
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword) public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
@ -77,8 +111,13 @@ public class AccountService : IAccountService
return Array.Empty<ApiException>(); return Array.Empty<ApiException>();
} }
public async Task<IEnumerable<ApiException>> ValidateUsername(string username) public async Task<IEnumerable<ApiException>> ValidateUsername(string? username)
{ {
if (string.IsNullOrWhiteSpace(username) || !AllowedUsernameRegex.IsMatch(username))
{
return [new ApiException(400, "Invalid username")];
}
// Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535 // Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null
&& x.NormalizedUserName == username.ToUpper())) && x.NormalizedUserName == username.ToUpper()))
@ -143,4 +182,113 @@ public class AccountService : IAccountService
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
} }
public async Task<bool> ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider)
{
var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser();
if (user.Id == defaultAdminUser.Id)
{
throw new KavitaException(await _localizationService.Translate(actingUserId, "cannot-change-identity-provider-original-user"));
}
// Allow changes if users aren't being synced
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
if (!oidcSettings.SyncUserSettings)
{
user.IdentityProvider = identityProvider;
await _unitOfWork.CommitAsync();
return false;
}
// Don't allow changes to the user if they're managed by oidc, and their identity provider isn't being changed to something else
if (user.IdentityProvider == IdentityProvider.OpenIdConnect && identityProvider == IdentityProvider.OpenIdConnect)
{
throw new KavitaException(await _localizationService.Translate(actingUserId, "oidc-managed"));
}
user.IdentityProvider = identityProvider;
await _unitOfWork.CommitAsync();
return user.IdentityProvider == IdentityProvider.OpenIdConnect;
}
public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole)
{
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList();
var currentLibrary = allLibraries.Where(l => l.AppUsers.Contains(user)).ToList();
List<Library> libraries;
if (hasAdminRole)
{
_logger.LogDebug("{UserId} is admin. Granting access to all libraries", user.Id);
libraries = allLibraries;
}
else
{
libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList();
}
var toRemove = currentLibrary.Except(libraries);
var toAdd = libraries.Except(currentLibrary);
foreach (var lib in toRemove)
{
lib.AppUsers ??= [];
lib.AppUsers.Remove(user);
user.RemoveSideNavFromLibrary(lib);
}
foreach (var lib in toAdd)
{
lib.AppUsers ??= [];
lib.AppUsers.Add(user);
user.CreateSideNavFromLibrary(lib);
}
}
public async Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles)
{
var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = roles.Contains(PolicyConstants.AdminRole);
if (!hasAdminRole)
{
roles.Add(PolicyConstants.PlebRole);
}
if (existingRoles.Except(roles).Any() || roles.Except(existingRoles).Any())
{
var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles);
if (!roleResult.Succeeded) return roleResult.Errors;
roleResult = await _userManager.AddToRolesAsync(user, roles);
if (!roleResult.Succeeded) return roleResult.Errors;
}
return [];
}
public void AddDefaultStreamsToUser(AppUser user)
{
foreach (var newStream in Seed.DefaultStreams.Select(_mapper.Map<AppUserDashboardStream, AppUserDashboardStream>))
{
user.DashboardStreams.Add(newStream);
}
foreach (var stream in Seed.DefaultSideNavStreams.Select(_mapper.Map<AppUserSideNavStream, AppUserSideNavStream>))
{
user.SideNavStreams.Add(stream);
}
}
public async Task AddDefaultReadingProfileToUser(AppUser user)
{
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Default Profile")
.WithKind(ReadingProfileKind.Default)
.Build();
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
await _unitOfWork.CommitAsync();
}
[GeneratedRegex(@"^[a-zA-Z0-9\-._@+/]*$")]
private static partial Regex AllowedUsernameRegexAttr();
} }

662
API/Services/OidcService.cs Normal file
View File

@ -0,0 +1,662 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Email;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using Hangfire;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace API.Services;
public interface IOidcService
{
/// <summary>
/// Returns the user authenticated with OpenID Connect
/// </summary>
/// <param name="request"></param>
/// <param name="principal"></param>
/// <returns></returns>
/// <exception cref="KavitaException">if any requirements aren't met</exception>
Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal);
/// <summary>
/// Refresh the token inside the cookie when it's close to expiring. And sync the user
/// </summary>
/// <param name="ctx"></param>
/// <returns></returns>
/// <remarks>If the token is refreshed successfully, updates the last active time of the suer</remarks>
Task<AppUser?> RefreshCookieToken(CookieValidatePrincipalContext ctx);
/// <summary>
/// Remove <see cref="AppUser.OidcId"/> from all users
/// </summary>
/// <returns></returns>
Task ClearOidcIds();
}
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
IUnitOfWork unitOfWork, IAccountService accountService, IEmailService emailService): IOidcService
{
public const string LibraryAccessPrefix = "library-";
public const string AgeRestrictionPrefix = "age-restriction-";
public const string IncludeUnknowns = "include-unknowns";
public const string RefreshToken = "refresh_token";
public const string IdToken = "id_token";
public const string ExpiresAt = "expires_at";
/// The name of the Auth Cookie set by .NET
public const string CookieName = ".AspNetCore.Cookies";
private OpenIdConnectConfiguration? _discoveryDocument;
private static readonly ConcurrentDictionary<string, bool> RefreshInProgress = new();
private static readonly ConcurrentDictionary<string, DateTimeOffset> LastFailedRefresh = new();
public async Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal)
{
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var oidcId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(oidcId))
{
throw new KavitaException("errors.oidc.missing-external-id");
}
var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences);
if (user != null) return user;
var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email))
{
throw new KavitaException("errors.oidc.missing-email");
}
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
{
throw new KavitaException("errors.oidc.email-not-verified");
}
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user != null)
{
// Don't allow taking over accounts
// This could happen if the user changes their email in OIDC, and then someone else uses the old one
if (!string.IsNullOrEmpty(user.OidcId))
{
throw new KavitaException("errors.oidc.email-in-use");
}
logger.LogDebug("User {UserName} has matched on email to {OidcId}", user.Id, oidcId);
user.OidcId = oidcId;
await unitOfWork.CommitAsync();
return user;
}
return await CreateNewAccount(request, principal, settings, oidcId);
}
public async Task<AppUser?> RefreshCookieToken(CookieValidatePrincipalContext ctx)
{
if (ctx.Principal == null) return null;
var user = await unitOfWork.UserRepository.GetUserByIdAsync(ctx.Principal.GetUserId()) ?? throw new UnauthorizedAccessException();
var key = ctx.Principal.GetUsername();
var refreshToken = ctx.Properties.GetTokenValue(RefreshToken);
if (string.IsNullOrEmpty(refreshToken)) return user;
var expiresAt = ctx.Properties.GetTokenValue(ExpiresAt);
if (string.IsNullOrEmpty(expiresAt)) return user;
// Do not spam refresh if it failed
if (LastFailedRefresh.TryGetValue(key, out var time) && time.AddMinutes(30) < DateTimeOffset.UtcNow) return user;
var tokenExpiry = DateTimeOffset.ParseExact(expiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
if (tokenExpiry >= DateTimeOffset.UtcNow.AddSeconds(30)) return user;
// Ensure we're not refreshing twice
if (!RefreshInProgress.TryAdd(key, true)) return user;
try
{
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var tokenResponse = await RefreshTokenAsync(settings, refreshToken);
if (!string.IsNullOrEmpty(tokenResponse.Error))
{
logger.LogTrace("Failed to refresh token : {Error} - {Description}", tokenResponse.Error, tokenResponse.ErrorDescription);
LastFailedRefresh.TryAdd(key, DateTimeOffset.UtcNow);
return user;
}
var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(tokenResponse.ExpiresIn));
ctx.Properties.UpdateTokenValue(ExpiresAt, newExpiresAt.ToString("o"));
ctx.Properties.UpdateTokenValue(RefreshToken, tokenResponse.RefreshToken);
ctx.Properties.UpdateTokenValue(IdToken, tokenResponse.IdToken);
ctx.ShouldRenew = true;
try
{
user.UpdateLastActive();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
if (unitOfWork.HasChanges())
{
await unitOfWork.CommitAsync();
}
if (string.IsNullOrEmpty(tokenResponse.IdToken))
{
logger.LogTrace("The OIDC provider did not return an id token in the refresh response, continuous sync is not supported");
return user;
}
await SyncUserSettings(ctx, settings, tokenResponse.IdToken, user);
logger.LogTrace("Automatically refreshed token for user {UserId}", ctx.Principal?.GetUserId());
}
finally
{
RefreshInProgress.TryRemove(key, out _);
LastFailedRefresh.TryRemove(key, out _);
}
return user;
}
public async Task ClearOidcIds()
{
var users = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in users)
{
user.OidcId = null;
}
await unitOfWork.CommitAsync();
}
/// <summary>
/// Tries to construct a new account from the OIDC Principal, may fail if required conditions aren't met
/// </summary>
/// <param name="request"></param>
/// <param name="principal"></param>
/// <param name="settings"></param>
/// <param name="oidcId"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
private async Task<AppUser?> CreateNewAccount(HttpRequest request, ClaimsPrincipal principal, OidcConfigDto settings, string oidcId)
{
var accessRoles = principal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix)
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
if (settings.SyncUserSettings && accessRoles.Count == 0)
{
throw new KavitaException("errors.oidc.role-not-assigned");
}
AppUser? user;
try
{
user = await NewUserFromOpenIdConnect(request, settings, principal, oidcId);
}
catch (KavitaException e)
{
throw;
}
catch (Exception e)
{
logger.LogError(e, "An error occured creating a new user");
throw new KavitaException("errors.oidc.creating-user");
}
if (user == null) return null;
var roles = await userManager.GetRolesAsync(user);
if (roles.Count == 0 || (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)))
{
throw new KavitaException("errors.oidc.disabled-account");
}
return user;
}
/// <summary>
/// Find the best available name from claims
/// </summary>
/// <param name="claimsPrincipal"></param>
/// <param name="orEqualTo">Also return if the claim is equal to this value</param>
/// <returns></returns>
public async Task<string?> FindBestAvailableName(ClaimsPrincipal claimsPrincipal, string? orEqualTo = null)
{
var nameCandidates = new[]
{
claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername),
claimsPrincipal.FindFirstValue(ClaimTypes.Name),
claimsPrincipal.FindFirstValue(ClaimTypes.GivenName),
claimsPrincipal.FindFirstValue(ClaimTypes.Surname)
};
foreach (var name in nameCandidates.Where(n => !string.IsNullOrEmpty(n)))
{
if (name == orEqualTo || await IsNameAvailable(name))
{
return name;
}
}
return null;
}
private async Task<bool> IsNameAvailable(string? name)
{
return !(await accountService.ValidateUsername(name)).Any();
}
private async Task<AppUser?> NewUserFromOpenIdConnect(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId)
{
if (!settings.ProvisionAccounts) return null;
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
if (string.IsNullOrWhiteSpace(emailClaim?.Value)) return null;
var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value;
logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name.Censor(), externalId);
var user = new AppUserBuilder(name, emailClaim.Value,
await unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build();
var res = await userManager.CreateAsync(user);
if (!res.Succeeded)
{
logger.LogError("Failed to create new user from OIDC: {Errors}",
res.Errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.creating-user");
}
if (claimsPrincipal.HasVerifiedEmail())
{
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
await userManager.ConfirmEmailAsync(user, token);
}
user.OidcId = externalId;
user.IdentityProvider = IdentityProvider.OpenIdConnect;
accountService.AddDefaultStreamsToUser(user);
await accountService.AddDefaultReadingProfileToUser(user);
await SyncUserSettings(request, settings, claimsPrincipal, user);
await SetDefaults(settings, user);
await unitOfWork.CommitAsync();
return user;
}
/// <summary>
/// Assign configured defaults (libraries, age ratings, roles) to the newly created user
/// </summary>
private async Task SetDefaults(OidcConfigDto settings, AppUser user)
{
if (settings.SyncUserSettings) return;
logger.LogDebug("Assigning defaults to newly created user; Roles: {Roles}, Libraries: {Libraries}, AgeRating: {AgeRating}, IncludeUnknowns: {IncludeUnknowns}",
settings.DefaultRoles, settings.DefaultLibraries, settings.DefaultAgeRestriction, settings.DefaultIncludeUnknowns);
// Assign roles
var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles);
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
// Assign libraries
await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole));
// Assign age rating
user.AgeRestriction = settings.DefaultAgeRestriction;
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
await unitOfWork.CommitAsync();
}
private async Task SyncUserSettings(CookieValidatePrincipalContext ctx, OidcConfigDto settings, string idToken, AppUser user)
{
if (!settings.SyncUserSettings) return;
try
{
var newPrincipal = await ParseIdToken(settings, idToken);
await SyncUserSettings(ctx.HttpContext.Request, settings, newPrincipal, user);
}
catch (KavitaException ex)
{
logger.LogError(ex, "Failed to sync user after token refresh");
throw new UnauthorizedAccessException(ex.Message);
}
}
/// <summary>
/// Updates roles, library access and age rating restriction. Will not modify the default admin
/// </summary>
/// <param name="request"></param>
/// <param name="settings"></param>
/// <param name="claimsPrincipal"></param>
/// <param name="user"></param>
public async Task SyncUserSettings(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
if (!settings.SyncUserSettings) return;
// Never sync the default user
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
if (defaultAdminUser.Id == user.Id) return;
logger.LogDebug("Syncing user {UserId} from OIDC", user.Id);
try
{
await SyncEmail(request, settings, claimsPrincipal, user);
await SyncUsername(claimsPrincipal, user);
await SyncRoles(settings, claimsPrincipal, user);
await SyncLibraries(settings, claimsPrincipal, user);
await SyncAgeRestriction(settings, claimsPrincipal, user);
if (unitOfWork.HasChanges())
{
await unitOfWork.CommitAsync();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to sync user {UserId} from OIDC", user.Id);
await unitOfWork.RollbackAsync();
throw new KavitaException("errors.oidc.syncing-user", ex);
}
}
private async Task SyncEmail(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email) || user.Email == email) return;
if (settings.RequireVerifiedEmail && !claimsPrincipal.HasVerifiedEmail())
{
throw new KavitaException("errors.oidc.email-not-verified");
}
// Ensure no other user uses this email
var other = await userManager.FindByEmailAsync(email);
if (other != null)
{
throw new KavitaException("errors.oidc.email-in-use");
}
// The email is verified, we can go ahead and change & confirm it
if (claimsPrincipal.HasVerifiedEmail())
{
var res = await userManager.SetEmailAsync(user, email);
if (!res.Succeeded)
{
logger.LogError("Failed to update email for user {UserId} from OIDC {Errors}", user.Id, res.Errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.failed-to-update-email");
}
user.EmailConfirmed = true;
await userManager.UpdateAsync(user);
return;
}
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
var isValidEmailAddress = !string.IsNullOrEmpty(user.Email) && emailService.IsValidEmail(user.Email);
var isEmailSetup = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup();
var shouldEmailUser = isEmailSetup || !isValidEmailAddress;
user.EmailConfirmed = !shouldEmailUser;
user.ConfirmationToken = token;
await userManager.UpdateAsync(user);
var emailLink = await emailService.GenerateEmailLink(request, user.ConfirmationToken, "confirm-email-update", email);
logger.LogCritical("[Update Email]: Automatic email update after OIDC sync, email Link for {UserId}: {Link}", user.Id, emailLink);
if (!shouldEmailUser)
{
logger.LogInformation("Cannot email admin, email not setup or admin email invalid");
return;
}
if (!isValidEmailAddress)
{
logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email.Censor());
return;
}
try
{
var invitingUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
BackgroundJob.Enqueue(() => emailService.SendEmailChangeEmail(new ConfirmationEmailDto()
{
EmailAddress = string.IsNullOrEmpty(user.Email) ? email : user.Email,
InstallId = BuildInfo.Version.ToString(),
InvitingUser = invitingUser.UserName,
ServerConfirmationLink = emailLink,
}));
}
catch (Exception)
{
/* Swallow exception */
}
}
private async Task SyncUsername(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var bestName = await FindBestAvailableName(claimsPrincipal, user.UserName);
if (bestName == null || bestName == user.UserName) return;
var res = await userManager.SetUserNameAsync(user, bestName);
if (!res.Succeeded)
{
logger.LogError("Failed to update username for user {UserId} to {NewUserName} from OIDC {Errors}", user.Id,
bestName.Censor(), res.Errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.failed-to-update-username");
}
}
private async Task SyncRoles(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var roles = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix)
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles);
var errors = (await accountService.UpdateRolesForUser(user, roles)).ToList();
if (errors.Any())
{
logger.LogError("Failed to sync roles {Errors}", errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.syncing-user");
}
}
private async Task SyncLibraries(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var libraryAccessPrefix = settings.RolesPrefix + LibraryAccessPrefix;
var libraryAccess = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, libraryAccessPrefix);
logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess);
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
// Distinct to ensure each library (id) is only present once
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).Distinct().ToList();
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
}
private async Task SyncAgeRestriction(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
logger.LogDebug("User {UserId} is admin, granting access to all age ratings", user.Id);
user.AgeRestriction = AgeRating.NotApplicable;
user.AgeRestrictionIncludeUnknowns = true;
return;
}
var ageRatingPrefix = settings.RolesPrefix + AgeRestrictionPrefix;
var ageRatings = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, ageRatingPrefix);
logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings);
if (ageRatings.Count == 0 || (ageRatings.Count == 1 && ageRatings.Contains(IncludeUnknowns)))
{
logger.LogDebug("No age restriction found in roles, setting to NotApplicable and Include Unknowns: {IncludeUnknowns}", settings.DefaultIncludeUnknowns);
user.AgeRestriction = AgeRating.NotApplicable;
user.AgeRestrictionIncludeUnknowns = true;
return;
}
var highestAgeRestriction = AgeRating.NotApplicable;
foreach (var ar in ageRatings)
{
if (!EnumExtensions.TryParse(ar, out AgeRating ageRating))
{
logger.LogDebug("Age Restriction role configured that failed to map to a known age rating: {RoleName}", AgeRestrictionPrefix+ar);
continue;
}
if (ageRating > highestAgeRestriction)
{
highestAgeRestriction = ageRating;
}
}
user.AgeRestriction = highestAgeRestriction;
user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns);
logger.LogDebug("Synced age restriction for user {UserId}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}",
user.Id, user.AgeRestriction, user.AgeRestrictionIncludeUnknowns);
}
/// <summary>
/// Loads the discovery document if not already loaded, then refreshed the tokens for the user
/// </summary>
/// <param name="dto"></param>
/// <param name="refreshToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private async Task<OpenIdConnectMessage> RefreshTokenAsync(OidcConfigDto dto, string refreshToken)
{
_discoveryDocument ??= await LoadOidcConfiguration(dto.Authority);
var msg = new
{
grant_type = RefreshToken,
refresh_token = refreshToken,
client_id = dto.ClientId,
client_secret = dto.Secret,
};
var json = await _discoveryDocument.TokenEndpoint
.AllowAnyHttpStatus()
.PostUrlEncodedAsync(msg)
.ReceiveString();
return new OpenIdConnectMessage(json);
}
/// <summary>
/// Loads the discovery document if not already loaded, then parses the given id token securely
/// </summary>
/// <param name="dto"></param>
/// <param name="idToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private async Task<ClaimsPrincipal> ParseIdToken(OidcConfigDto dto, string idToken)
{
_discoveryDocument ??= await LoadOidcConfiguration(dto.Authority);
var tokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = _discoveryDocument.Issuer,
ValidAudience = dto.ClientId,
IssuerSigningKeys = _discoveryDocument.SigningKeys,
ValidateIssuerSigningKey = true,
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(idToken, tokenValidationParameters, out _);
return principal;
}
/// <summary>
/// Loads OpenIdConnectConfiguration, includes <see cref="OpenIdConnectConfiguration.SigningKeys"/>
/// </summary>
/// <param name="authority"></param>
/// <returns></returns>
private static async Task<OpenIdConnectConfiguration> LoadOidcConfiguration(string authority)
{
var hasTrailingSlash = authority.EndsWith('/');
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
var manager = new ConfigurationManager<OpenIdConnectConfiguration>(
url,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") }
);
return await manager.GetConfigurationAsync();
}
/// <summary>
/// Return a list of claims in the same way the NativeJWT token would map them.
/// Optionally include original claims if the claims are needed later in the pipeline
/// </summary>
/// <param name="services"></param>
/// <param name="principal"></param>
/// <param name="user"></param>
/// <param name="includeOriginalClaims"></param>
/// <returns></returns>
public static async Task<List<Claim>> ConstructNewClaimsList(IServiceProvider services, ClaimsPrincipal? principal, AppUser user, bool includeOriginalClaims = true)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
new(ClaimTypes.Name, user.UserName ?? string.Empty),
};
var userManager = services.GetRequiredService<UserManager<AppUser>>();
var roles = await userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
if (includeOriginalClaims)
{
claims.AddRange(principal?.Claims ?? []);
}
return claims;
}
}

View File

@ -2,6 +2,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs; using API.DTOs;
@ -13,13 +15,13 @@ using API.Entities.MetadataMatching;
using API.Extensions; using API.Extensions;
using API.Logging; using API.Logging;
using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner;
using Flurl.Http;
using Hangfire; using Hangfire;
using Kavita.Common; using Kavita.Common;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SharpCompress.Common; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace API.Services; namespace API.Services;
@ -35,6 +37,12 @@ public interface ISettingsService
/// <returns></returns> /// <returns></returns>
Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings); Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings);
Task<ServerSettingDto> UpdateSettings(ServerSettingDto updateSettingsDto); Task<ServerSettingDto> UpdateSettings(ServerSettingDto updateSettingsDto);
/// <summary>
/// Check if the server can reach the authority at the given uri
/// </summary>
/// <param name="authority"></param>
/// <returns></returns>
Task<bool> IsValidAuthority(string authority);
} }
@ -45,16 +53,18 @@ public class SettingsService : ISettingsService
private readonly ILibraryWatcher _libraryWatcher; private readonly ILibraryWatcher _libraryWatcher;
private readonly ITaskScheduler _taskScheduler; private readonly ITaskScheduler _taskScheduler;
private readonly ILogger<SettingsService> _logger; private readonly ILogger<SettingsService> _logger;
private readonly IOidcService _oidcService;
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
ILogger<SettingsService> logger) ILogger<SettingsService> logger, IOidcService oidcService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_directoryService = directoryService; _directoryService = directoryService;
_libraryWatcher = libraryWatcher; _libraryWatcher = libraryWatcher;
_taskScheduler = taskScheduler; _taskScheduler = taskScheduler;
_logger = logger; _logger = logger;
_oidcService = oidcService;
} }
/// <summary> /// <summary>
@ -292,6 +302,7 @@ public class SettingsService : ISettingsService
} }
var updateTask = false; var updateTask = false;
var updatedOidcSettings = false;
foreach (var setting in currentSettings) foreach (var setting in currentSettings)
{ {
if (setting.Key == ServerSettingKey.OnDeckProgressDays && if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
@ -329,7 +340,7 @@ public class SettingsService : ISettingsService
updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
UpdateEmailSettings(setting, updateSettingsDto); UpdateEmailSettings(setting, updateSettingsDto);
updatedOidcSettings = await UpdateOidcSettings(setting, updateSettingsDto) || updatedOidcSettings;
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
@ -481,6 +492,17 @@ public class SettingsService : ISettingsService
BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks());
} }
if (updatedOidcSettings)
{
Configuration.OidcSettings = new Configuration.OpenIdConnectSettings
{
Authority = updateSettingsDto.OidcConfig.Authority,
ClientId = updateSettingsDto.OidcConfig.ClientId,
Secret = updateSettingsDto.OidcConfig.Secret,
CustomScopes = updateSettingsDto.OidcConfig.CustomScopes,
};
}
if (updateSettingsDto.EnableFolderWatching) if (updateSettingsDto.EnableFolderWatching)
{ {
BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching());
@ -503,6 +525,29 @@ public class SettingsService : ISettingsService
return updateSettingsDto; return updateSettingsDto;
} }
public async Task<bool> IsValidAuthority(string authority)
{
if (string.IsNullOrEmpty(authority))
{
return false;
}
try
{
var hasTrailingSlash = authority.EndsWith('/');
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
var json = await url.GetStringAsync();
var config = OpenIdConnectConfiguration.Create(json);
return config.Issuer == authority;
}
catch (Exception e)
{
_logger.LogDebug(e, "OpenIdConfiguration failed: {Reason}", e.Message);
return false;
}
}
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
{ {
_directoryService.ExistOrCreate(bookmarkDirectory); _directoryService.ExistOrCreate(bookmarkDirectory);
@ -536,6 +581,45 @@ public class SettingsService : ISettingsService
return false; return false;
} }
/// <summary>
/// Updates oidc settings and return true if a change was made
/// </summary>
/// <param name="setting"></param>
/// <param name="updateSettingsDto"></param>
/// <returns></returns>
/// <remarks>Does not commit any changes</remarks>
/// <exception cref="KavitaException">If the authority is invalid</exception>
private async Task<bool> UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key != ServerSettingKey.OidcConfiguration) return false;
if (updateSettingsDto.OidcConfig.RolesClaim.Trim() == string.Empty)
{
updateSettingsDto.OidcConfig.RolesClaim = ClaimTypes.Role;
}
var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig);
if (setting.Value == newValue) return false;
var currentConfig = JsonSerializer.Deserialize<OidcConfigDto>(setting.Value)!;
if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority)
{
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
{
throw new KavitaException("oidc-invalid-authority");
}
_logger.LogWarning("OIDC Authority is changing, clearing all external ids");
await _oidcService.ClearOidcIds();
}
setting.Value = newValue;
_unitOfWork.SettingsRepository.Update(setting);
return true;
}
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{ {
if (setting.Key == ServerSettingKey.EmailHost && if (setting.Key == ServerSettingKey.EmailHost &&

View File

@ -0,0 +1,59 @@
using System;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Caching.Memory;
namespace API.Services.Store;
public class CustomTicketStore(IMemoryCache cache): ITicketStore
{
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
// Note: It might not be needed to make this cryptographic random, but better safe than sorry
var bytes = new byte[32];
RandomNumberGenerator.Fill(bytes);
var key = Convert.ToBase64String(bytes);
await RenewAsync(key, ticket);
return key;
}
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var options = new MemoryCacheEntryOptions
{
Priority = CacheItemPriority.NeverRemove,
Size = 1,
};
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
{
options.AbsoluteExpiration = expiresUtc.Value;
}
else
{
options.SlidingExpiration = TimeSpan.FromDays(7);
}
cache.Set(key, ticket, options);
return Task.CompletedTask;
}
public Task<AuthenticationTicket> RetrieveAsync(string key)
{
return Task.FromResult(cache.Get<AuthenticationTicket>(key));
}
public Task RemoveAsync(string key)
{
cache.Remove(key);
return Task.CompletedTask;
}
}

View File

@ -248,7 +248,8 @@ public class StatsService : IStatsService
DotnetVersion = Environment.Version.ToString(), DotnetVersion = Environment.Version.ToString(),
OpdsEnabled = serverSettings.EnableOpds, OpdsEnabled = serverSettings.EnableOpds,
EncodeMediaAs = serverSettings.EncodeMediaAs, EncodeMediaAs = serverSettings.EncodeMediaAs,
MatchedMetadataEnabled = mediaSettings.Enabled MatchedMetadataEnabled = mediaSettings.Enabled,
OidcEnabled = !string.IsNullOrEmpty(serverSettings.OidcConfig.Authority),
}; };
dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; dto.OsLocale = CultureInfo.CurrentCulture.EnglishName;
@ -308,6 +309,7 @@ public class StatsService : IStatsService
libDto.UsingFolderWatching = library.FolderWatching; libDto.UsingFolderWatching = library.FolderWatching;
libDto.CreateCollectionsFromMetadata = library.ManageCollections; libDto.CreateCollectionsFromMetadata = library.ManageCollections;
libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; libDto.CreateReadingListsFromMetadata = library.ManageReadingLists;
libDto.EnabledMetadata = library.EnableMetadata;
libDto.LibraryType = library.Type; libDto.LibraryType = library.Type;
dto.Libraries.Add(libDto); dto.Libraries.Add(libDto);
@ -353,7 +355,9 @@ public class StatsService : IStatsService
userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList(); userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList();
userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count; userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count;
userDto.SmartFilterCreatedCount = user.SmartFilters.Count; userDto.SmartFilterCreatedCount = user.SmartFilters.Count;
userDto.IsSharingReviews = user.UserPreferences.ShareReviews;
userDto.WantToReadSeriesCount = user.WantToRead.Count; userDto.WantToReadSeriesCount = user.WantToRead.Count;
userDto.IdentityProvider = user.IdentityProvider;
if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries)) if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries))
{ {

View File

@ -136,7 +136,7 @@ public class Startup
} }
}); });
services.AddCors(); services.AddCors();
services.AddIdentityServices(_config); services.AddIdentityServices(_config, _env);
services.AddSwaggerGen(c => services.AddSwaggerGen(c =>
{ {
c.SwaggerDoc("v1", new OpenApiInfo c.SwaggerDoc("v1", new OpenApiInfo

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
@ -14,6 +15,8 @@ public static class Configuration
public const int DefaultHttpPort = 5000; public const int DefaultHttpPort = 5000;
public const int DefaultTimeOutSecs = 90; public const int DefaultTimeOutSecs = 90;
public const long DefaultCacheMemory = 75; public const long DefaultCacheMemory = 75;
public const string DefaultOidcAuthority = "";
public const string DefaultOidcClientId = "kavita";
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
@ -50,6 +53,13 @@ public static class Configuration
set => SetCacheSize(GetAppSettingFilename(), value); set => SetCacheSize(GetAppSettingFilename(), value);
} }
/// <remarks>You must set this object to update the settings, setting one if it's fields will not save to disk</remarks>
public static OpenIdConnectSettings OidcSettings
{
get => GetOpenIdConnectSettings(GetAppSettingFilename());
set => SetOpenIdConnectSettings(GetAppSettingFilename(), value);
}
public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename()); public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename());
private static string GetAppSettingFilename() private static string GetAppSettingFilename()
@ -312,6 +322,43 @@ public static class Configuration
} }
#endregion #endregion
#region OIDC
private static OpenIdConnectSettings GetOpenIdConnectSettings(string filePath)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
return jsonObj.OpenIdConnectSettings ?? new OpenIdConnectSettings();
}
catch (Exception ex)
{
Console.WriteLine("Error reading app settings: " + ex.Message);
}
return new OpenIdConnectSettings();
}
private static void SetOpenIdConnectSettings(string filePath, OpenIdConnectSettings value)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
jsonObj.OpenIdConnectSettings = value;
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
catch (Exception)
{
/* Swallow exception */
}
}
#endregion
private sealed class AppSettings private sealed class AppSettings
{ {
public string TokenKey { get; set; } public string TokenKey { get; set; }
@ -326,6 +373,20 @@ public static class Configuration
public long Cache { get; set; } = DefaultCacheMemory; public long Cache { get; set; } = DefaultCacheMemory;
// ReSharper disable once MemberHidesStaticFromOuterClass // ReSharper disable once MemberHidesStaticFromOuterClass
public bool AllowIFraming { get; init; } = false; public bool AllowIFraming { get; init; } = false;
public OpenIdConnectSettings OpenIdConnectSettings { get; set; } = new();
#pragma warning restore S3218 #pragma warning restore S3218
} }
public class OpenIdConnectSettings
{
public string Authority { get; set; } = DefaultOidcAuthority;
public string ClientId { get; set; } = DefaultOidcClientId;
public string Secret { get; set; } = string.Empty;
public List<string> CustomScopes { get; set; } = [];
public bool Enabled =>
!string.IsNullOrEmpty(Authority) &&
!string.IsNullOrEmpty(ClientId) &&
!string.IsNullOrEmpty(Secret);
}
} }

View File

@ -20,4 +20,4 @@
</PackageReference> </PackageReference>
<PackageReference Include="xunit.assert" Version="2.9.3" /> <PackageReference Include="xunit.assert" Version="2.9.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -99,7 +99,8 @@
"sslKey": "./ssl/server.key", "sslKey": "./ssl/server.key",
"sslCert": "./ssl/server.crt", "sslCert": "./ssl/server.crt",
"ssl": false, "ssl": false,
"buildTarget": "kavita-webui:build" "buildTarget": "kavita-webui:build",
"proxyConfig": "proxy.conf.json"
}, },
"configurations": { "configurations": {
"production": { "production": {

37
UI/Web/proxy.conf.json Normal file
View File

@ -0,0 +1,37 @@
{
"/api": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/hubs": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"ws": true
},
"/oidc/login": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/oidc/logout": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/signin-oidc": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true
},
"/signout-callback-oidc": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true
}
}

View File

@ -1,16 +1,21 @@
import { Injectable } from '@angular/core'; import {inject, Injectable} from '@angular/core';
import { CanActivate, Router } from '@angular/router'; import { CanActivate, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { AccountService } from '../_services/account.service'; import { AccountService } from '../_services/account.service';
import {TranslocoService} from "@jsverse/transloco"; import {TranslocoService} from "@jsverse/transloco";
import {APP_BASE_HREF} from "@angular/common";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
public urlKey: string = 'kavita--auth-intersection-url';
public static urlKey: string = 'kavita--auth-intersection-url';
baseURL = inject(APP_BASE_HREF);
constructor(private accountService: AccountService, constructor(private accountService: AccountService,
private router: Router, private router: Router,
private toastr: ToastrService, private toastr: ToastrService,
@ -23,7 +28,10 @@ export class AuthGuard implements CanActivate {
return true; return true;
} }
localStorage.setItem(this.urlKey, window.location.pathname); const path = window.location.pathname;
if (path !== '/login' && !path.startsWith(this.baseURL + "registration") && path !== '') {
localStorage.setItem(AuthGuard.urlKey, path);
}
this.router.navigateByUrl('/login'); this.router.navigateByUrl('/login');
return false; return false;
}) })

View File

@ -1,4 +1,4 @@
import {Injectable} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -6,9 +6,14 @@ import { ToastrService } from 'ngx-toastr';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { AccountService } from '../_services/account.service'; import { AccountService } from '../_services/account.service';
import {translate, TranslocoService} from "@jsverse/transloco"; import {translate, TranslocoService} from "@jsverse/transloco";
import {AuthGuard} from "../_guards/auth.guard";
import {APP_BASE_HREF} from "@angular/common";
@Injectable() @Injectable()
export class ErrorInterceptor implements HttpInterceptor { export class ErrorInterceptor implements HttpInterceptor {
baseURL = inject(APP_BASE_HREF);
constructor(private router: Router, private toastr: ToastrService, constructor(private router: Router, private toastr: ToastrService,
private accountService: AccountService, private accountService: AccountService,
private translocoService: TranslocoService) {} private translocoService: TranslocoService) {}
@ -26,7 +31,7 @@ export class ErrorInterceptor implements HttpInterceptor {
this.handleValidationError(error); this.handleValidationError(error);
break; break;
case 401: case 401:
this.handleAuthError(error); this.handleAuthError(request, error);
break; break;
case 404: case 404:
this.handleNotFound(error); this.handleNotFound(error);
@ -114,19 +119,31 @@ export class ErrorInterceptor implements HttpInterceptor {
console.error('500 error:', error); console.error('500 error:', error);
} }
private handleAuthError(error: any) { private handleAuthError(req: HttpRequest<unknown>, error: any) {
// Special hack for register url, to not care about auth // Special hack for register url, to not care about auth
if (location.href.includes('/registration/confirm-email?token=')) { if (location.href.includes('/registration/confirm-email?token=')) {
return; return;
} }
const path = window.location.pathname;
if (path !== '/login' && !path.startsWith(this.baseURL+"registration") && path !== '') {
localStorage.setItem(AuthGuard.urlKey, path);
}
if (error.error && error.error !== 'Unauthorized') {
this.toast(translate(error.error));
}
// NOTE: Signin has error.error or error.statusText available. // NOTE: Signin has error.error or error.statusText available.
// if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334 // if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334
this.accountService.logout();
// Ensure AutoLogin is skipped when the OIDC endpoint is called
this.accountService.logout(req.method === 'GET' && req.url.endsWith('/api/account'));
} }
// Assume the title is already translated // Assume the title is already translated
private toast(message: string, title?: string) { private toast(message: string, title?: string) {
if (message.startsWith('errors.')) { if ((message+'').startsWith('errors.')) {
this.toastr.error(this.translocoService.translate(message), title); this.toastr.error(this.translocoService.translate(message), title);
} else { } else {
this.toastr.error(message, title); this.toastr.error(message, title);

View File

@ -10,17 +10,15 @@ export class JwtInterceptor implements HttpInterceptor {
constructor(private accountService: AccountService) {} constructor(private accountService: AccountService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return this.accountService.currentUser$.pipe( const user = this.accountService.currentUserSignal();
take(1), if (user && user.token) {
switchMap(user => { request = request.clone({
if (user) { setHeaders: {
request = request.clone({ Authorization: `Bearer ${user.token}`
setHeaders: {
Authorization: `Bearer ${user.token}`
}
});
} }
return next.handle(request); });
})); }
return next.handle(request);
} }
} }

View File

@ -1,5 +1,6 @@
import {AgeRestriction} from '../metadata/age-restriction'; import {AgeRestriction} from '../metadata/age-restriction';
import {Library} from '../library/library'; import {Library} from '../library/library';
import {IdentityProvider} from "../user";
export interface Member { export interface Member {
id: number; id: number;
@ -13,4 +14,5 @@ export interface Member {
libraries: Library[]; libraries: Library[];
ageRestriction: AgeRestriction; ageRestriction: AgeRestriction;
isPending: boolean; isPending: boolean;
identityProvider: IdentityProvider;
} }

View File

@ -13,4 +13,12 @@ export interface User {
ageRestriction: AgeRestriction; ageRestriction: AgeRestriction;
hasRunScrobbleEventGeneration: boolean; hasRunScrobbleEventGeneration: boolean;
scrobbleEventGenerationRan: string; // datetime scrobbleEventGenerationRan: string; // datetime
identityProvider: IdentityProvider,
} }
export enum IdentityProvider {
Kavita = 0,
OpenIdConnect = 1,
}
export const IdentityProviders: IdentityProvider[] = [IdentityProvider.Kavita, IdentityProvider.OpenIdConnect];

View File

@ -12,13 +12,17 @@ export class AgeRatingPipe implements PipeTransform {
private readonly translocoService = inject(TranslocoService); private readonly translocoService = inject(TranslocoService);
transform(value: AgeRating | AgeRatingDto | undefined): string { transform(value: AgeRating | AgeRatingDto | undefined | string): string {
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown'); if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
if (value.hasOwnProperty('title')) { if (value.hasOwnProperty('title')) {
return (value as AgeRatingDto).title; return (value as AgeRatingDto).title;
} }
if (typeof value === 'string') {
value = parseInt(value, 10) as AgeRating;
}
switch (value) { switch (value) {
case AgeRating.Unknown: case AgeRating.Unknown:
return this.translocoService.translate('age-rating-pipe.unknown'); return this.translocoService.translate('age-rating-pipe.unknown');

View File

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {IdentityProvider} from "../_models/user";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'identityProviderPipe'
})
export class IdentityProviderPipePipe implements PipeTransform {
transform(value: IdentityProvider): string {
switch (value) {
case IdentityProvider.Kavita:
return translate("identity-provider-pipe.kavita");
case IdentityProvider.OpenIdConnect:
return translate("identity-provider-pipe.oidc");
}
}
}

View File

@ -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 {DestroyRef, inject, Injectable} from '@angular/core';
import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators'; import {filter, map, switchMap, tap} from 'rxjs/operators';
@ -13,7 +13,7 @@ import {UserUpdateEvent} from '../_models/events/user-update-event';
import {AgeRating} from '../_models/metadata/age-rating'; import {AgeRating} from '../_models/metadata/age-rating';
import {AgeRestriction} from '../_models/metadata/age-restriction'; import {AgeRestriction} from '../_models/metadata/age-restriction';
import {TextResonse} from '../_types/text-response'; import {TextResonse} from '../_types/text-response';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {Action} from "./action-factory.service"; import {Action} from "./action-factory.service";
import {LicenseService} from "./license.service"; import {LicenseService} from "./license.service";
import {LocalizationService} from "./localization.service"; import {LocalizationService} from "./localization.service";
@ -63,7 +63,7 @@ export class AccountService {
return this.hasAdminRole(u); return this.hasAdminRole(u);
}), shareReplay({bufferSize: 1, refCount: true})); }), shareReplay({bufferSize: 1, refCount: true}));
public readonly currentUserSignal = toSignal(this.currentUserSource);
/** /**
* SetTimeout handler for keeping track of refresh token call * SetTimeout handler for keeping track of refresh token call
@ -205,14 +205,22 @@ export class AccountService {
); );
} }
getAccount() {
return this.httpClient.get<User>(this.baseUrl + 'account').pipe(
tap((response: User) => {
const user = response;
if (user) {
this.setCurrentUser(user);
}
}),
takeUntilDestroyed(this.destroyRef)
);
}
setCurrentUser(user?: User, refreshConnections = true) { setCurrentUser(user?: User, refreshConnections = true) {
const isSameUser = this.currentUser === user; const isSameUser = this.currentUser === user;
if (user) { if (user) {
user.roles = [];
const roles = this.getDecodedToken(user.token).role;
Array.isArray(roles) ? user.roles = roles : user.roles.push(roles);
localStorage.setItem(this.userKey, JSON.stringify(user)); localStorage.setItem(this.userKey, JSON.stringify(user));
localStorage.setItem(AccountService.lastLoginKey, user.username); localStorage.setItem(AccountService.lastLoginKey, user.username);
@ -240,18 +248,30 @@ export class AccountService {
this.messageHub.createHubConnection(this.currentUser); this.messageHub.createHubConnection(this.currentUser);
this.licenseService.hasValidLicense().subscribe(); this.licenseService.hasValidLicense().subscribe();
} }
this.startRefreshTokenTimer(); if (this.currentUser.token) {
this.startRefreshTokenTimer();
}
} }
} }
logout() { logout(skipAutoLogin: boolean = false) {
const user = this.currentUserSignal();
if (!user) return;
localStorage.removeItem(this.userKey); localStorage.removeItem(this.userKey);
this.currentUserSource.next(undefined); this.currentUserSource.next(undefined);
this.currentUser = undefined; this.currentUser = undefined;
this.stopRefreshTokenTimer(); this.stopRefreshTokenTimer();
this.messageHub.stopHubConnection(); this.messageHub.stopHubConnection();
// Upon logout, perform redirection
this.router.navigateByUrl('/login'); if (!user.token) {
window.location.href = '/oidc/logout';
return;
}
this.router.navigate(['/login'], {
queryParams: {skipAutoLogin: skipAutoLogin}
});
} }
@ -269,6 +289,11 @@ export class AccountService {
); );
} }
isOidcAuthenticated() {
return this.httpClient.get<string>(this.baseUrl + 'account/oidc-authenticated', TextResonse)
.pipe(map(res => res == "true"));
}
isEmailConfirmed() { isEmailConfirmed() {
return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed'); return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed');
} }
@ -410,7 +435,8 @@ export class AccountService {
private refreshToken() { private refreshToken() {
if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of(); if (this.currentUser === null || this.currentUser === undefined || !this.isOnline || !this.currentUser.token) return of();
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
{token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
if (this.currentUser) { if (this.currentUser) {

View File

@ -11,6 +11,8 @@ import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {WikiLink} from "../_models/wiki"; import {WikiLink} from "../_models/wiki";
import {AuthGuard} from "../_guards/auth.guard";
import {SettingsService} from "../admin/settings.service";
/** /**
* NavItem used to construct the dropdown or NavLinkModal on mobile * NavItem used to construct the dropdown or NavLinkModal on mobile
@ -173,10 +175,24 @@ export class NavService {
} }
logout() { logout() {
this.accountService.logout();
this.hideNavBar(); this.hideNavBar();
this.hideSideNav(); this.hideSideNav();
this.router.navigateByUrl('/login'); this.accountService.logout();
}
handleLogin() {
this.showNavBar();
this.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem(AuthGuard.urlKey);
if (pageResume && pageResume !== '/login') {
localStorage.setItem(AuthGuard.urlKey, '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem(AuthGuard.urlKey, '');
this.router.navigateByUrl('/home');
}
} }
/** /**

View File

@ -0,0 +1,25 @@
import {AgeRating} from "../../_models/metadata/age-rating";
export interface OidcPublicConfig {
autoLogin: boolean;
disablePasswordAuthentication: boolean;
providerName: string;
enabled: boolean;
}
export interface OidcConfig extends OidcPublicConfig {
authority: string;
clientId: string;
secret: string;
provisionAccounts: boolean;
requireVerifiedEmail: boolean;
syncUserSettings: boolean;
rolesPrefix: string;
rolesClaim: string;
customScopes: string[];
defaultRoles: string[];
defaultLibraries: number[];
defaultAgeRestriction: AgeRating;
defaultIncludeUnknowns: boolean;
}

View File

@ -1,6 +1,7 @@
import {EncodeFormat} from "./encode-format"; import {EncodeFormat} from "./encode-format";
import {CoverImageSize} from "./cover-image-size"; import {CoverImageSize} from "./cover-image-size";
import {SmtpConfig} from "./smtp-config"; import {SmtpConfig} from "./smtp-config";
import {OidcConfig} from "./oidc-config";
export interface ServerSettings { export interface ServerSettings {
cacheDirectory: string; cacheDirectory: string;
@ -25,6 +26,7 @@ export interface ServerSettings {
onDeckUpdateDays: number; onDeckUpdateDays: number;
coverImageSize: CoverImageSize; coverImageSize: CoverImageSize;
smtpConfig: SmtpConfig; smtpConfig: SmtpConfig;
oidcConfig: OidcConfig;
installId: string; installId: string;
installVersion: string; installVersion: string;
} }

View File

@ -1,17 +1,41 @@
<ng-container *transloco="let t; read: 'edit-user'"> <ng-container *transloco="let t; prefix: 'edit-user'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5> <h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member().username | sentenceCase}}</h5>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button> </button>
</div> </div>
<div class="modal-body scrollable-modal"> <div class="modal-body scrollable-modal">
@if (!isLocked() && member().identityProvider === IdentityProvider.OpenIdConnect) {
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('out-of-sync')}}
</div>
}
@if (isLocked()) {
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('oidc-managed')}}
</div>
}
<form [formGroup]="userForm"> <form [formGroup]="userForm">
<h4>{{t('account-detail-title')}}</h4> <h4>{{t('account-detail-title')}}</h4>
<div class="row g-0 mb-2"> <div class="row g-0 mb-2">
<div class="col-md-6 col-sm-12 pe-4"> <div class="col-md-4 col-sm-12 pe-4">
@if (userForm.get('identityProvider'); as formControl) {
<label for="identityProvider" class="form-label">{{t('identity-provider')}}</label>
<select class="form-select" id="identityProvider" formControlName="identityProvider">
@for (idp of IdentityProviders; track idp) {
<option [value]="idp">{{idp | identityProviderPipe}}</option>
}
</select>
<span class="text-muted">{{t('identity-provider-tooltip')}}</span>
}
</div>
<div class="col-md-4 col-sm-12 pe-4">
@if(userForm.get('username'); as formControl) { @if(userForm.get('username'); as formControl) {
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">{{t('username')}}</label> <label for="username" class="form-label">{{t('username')}}</label>
@ -33,7 +57,7 @@
</div> </div>
} }
</div> </div>
<div class="col-md-6 col-sm-12"> <div class="col-md-4 col-sm-12">
@if(userForm.get('email'); as formControl) { @if(userForm.get('email'); as formControl) {
<div class="mb-3" style="width:100%"> <div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label> <label for="email" class="form-label">{{t('email')}}</label>
@ -63,17 +87,17 @@
<div class="row g-0 mb-3"> <div class="row g-0 mb-3">
<div class="col-md-12"> <div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector> <app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member()"></app-restriction-selector>
</div> </div>
</div> </div>
<div class="row g-0 mb-3"> <div class="row g-0 mb-3">
<div class="col-md-6 pe-4"> <div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector> <app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member()"></app-role-selector>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector> <app-library-selector (selected)="updateLibrarySelection($event)" [member]="member()"></app-library-selector>
</div> </div>
</div> </div>
</form> </form>
@ -83,7 +107,7 @@
<button type="button" class="btn btn-secondary" (click)="close()"> <button type="button" class="btn btn-secondary" (click)="close()">
{{t('cancel')}} {{t('cancel')}}
</button> </button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid"> <button type="button" class="btn btn-primary" (click)="save()" [disabled]="isLocked() || isSaving || !userForm.valid">
@if (isSaving) { @if (isSaving) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
} }

View File

@ -0,0 +1,3 @@
.text-muted {
font-size: 0.75rem;
}

View File

@ -1,4 +1,13 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
model,
OnInit
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction'; import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
@ -9,20 +18,23 @@ import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component'; import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
import {LibrarySelectorComponent} from '../library-selector/library-selector.component'; import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
import {RoleSelectorComponent} from '../role-selector/role-selector.component'; import {RoleSelectorComponent} from '../role-selector/role-selector.component';
import {AsyncPipe, NgIf} from '@angular/common'; import {AsyncPipe} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {debounceTime, distinctUntilChanged, Observable, startWith, switchMap, tap} from "rxjs"; import {debounceTime, distinctUntilChanged, Observable, startWith, tap} from "rxjs";
import {map} from "rxjs/operators"; import {map} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ServerSettings} from "../_models/server-settings";
import {IdentityProvider, IdentityProviders} from "../../_models/user";
import {IdentityProviderPipePipe} from "../../_pipes/identity-provider.pipe";
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/; const AllowedUsernameCharacters = /^[a-zA-Z0-9\-._@+/]*$/;
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@Component({ @Component({
selector: 'app-edit-user', selector: 'app-edit-user',
templateUrl: './edit-user.component.html', templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'], styleUrls: ['./edit-user.component.scss'],
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe], imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, IdentityProviderPipePipe],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class EditUserComponent implements OnInit { export class EditUserComponent implements OnInit {
@ -32,7 +44,14 @@ export class EditUserComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
protected readonly modal = inject(NgbActiveModal); protected readonly modal = inject(NgbActiveModal);
@Input({required: true}) member!: Member; member = model.required<Member>();
settings = model.required<ServerSettings>();
isLocked = computed(() => {
const setting = this.settings();
const member = this.member();
return setting.oidcConfig.syncUserSettings && member.identityProvider === IdentityProvider.OpenIdConnect;
});
selectedRoles: Array<string> = []; selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = []; selectedLibraries: Array<number> = [];
@ -52,18 +71,29 @@ export class EditUserComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required])); this.userForm.addControl('email', new FormControl(this.member().email, [Validators.required]));
this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
this.userForm.addControl('identityProvider', new FormControl(this.member().identityProvider, [Validators.required]));
this.userForm.get('identityProvider')!.valueChanges.pipe(
tap(value => {
const newIdentityProvider = parseInt(value, 10) as IdentityProvider;
if (newIdentityProvider === IdentityProvider.OpenIdConnect) return;
this.member.set({
...this.member(),
identityProvider: newIdentityProvider,
})
})).subscribe();
this.isEmailInvalid$ = this.userForm.get('email')!.valueChanges.pipe( this.isEmailInvalid$ = this.userForm.get('email')!.valueChanges.pipe(
startWith(this.member.email), startWith(this.member().email),
distinctUntilChanged(), distinctUntilChanged(),
debounceTime(10), debounceTime(10),
map(value => !EmailRegex.test(value)), map(value => !EmailRegex.test(value)),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
this.selectedRestriction = this.member.ageRestriction; this.selectedRestriction = this.member().ageRestriction;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
@ -88,14 +118,23 @@ export class EditUserComponent implements OnInit {
save() { save() {
const model = this.userForm.getRawValue(); const model = this.userForm.getRawValue();
model.userId = this.member.id; model.userId = this.member().id;
model.roles = this.selectedRoles; model.roles = this.selectedRoles;
model.libraries = this.selectedLibraries; model.libraries = this.selectedLibraries;
model.ageRestriction = this.selectedRestriction; model.ageRestriction = this.selectedRestriction;
model.identityProvider = parseInt(model.identityProvider, 10) as IdentityProvider;
this.accountService.update(model).subscribe(() => {
this.modal.close(true); this.accountService.update(model).subscribe({
next: () => {
this.modal.close(true);
},
error: err => {
console.error(err);
}
}); });
} }
protected readonly IdentityProvider = IdentityProvider;
protected readonly IdentityProviders = IdentityProviders;
} }

View File

@ -3,7 +3,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter, EventEmitter,
inject, inject, input,
Input, Input,
OnInit, OnInit,
Output Output
@ -29,6 +29,8 @@ export class LibrarySelectorComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
@Input() member: Member | undefined; @Input() member: Member | undefined;
preSelectedLibraries = input<number[]>([]);
@Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>(); @Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>();
allLibraries: Library[] = []; allLibraries: Library[] = [];
@ -61,6 +63,14 @@ export class LibrarySelectorComponent implements OnInit {
}); });
this.selectAll = this.selections.selected().length === this.allLibraries.length; this.selectAll = this.selections.selected().length === this.allLibraries.length;
this.selected.emit(this.selections.selected()); this.selected.emit(this.selections.selected());
} else if (this.preSelectedLibraries().length > 0) {
this.preSelectedLibraries().forEach((id) => {
const foundLib = this.allLibraries.find(lib => lib.id === id);
if (foundLib) {
this.selections.toggle(foundLib, true, (a, b) => a.name === b.name);
}
});
this.selectAll = this.selections.selected().length === this.allLibraries.length;
} }
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }

View File

@ -0,0 +1,282 @@
<ng-container *transloco="let t; prefix:'manage-oidc-connect'">
<div class="position-relative">
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save(true)">{{t('save')}}</button>
</div>
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<h4>{{t('provider-title')}}</h4>
<div class="text-muted" [innerHtml]="t('provider-tooltip') | safeHtml"></div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('authority'); as formControl) {
<app-setting-item [title]="t('authority-label')" [subtitle]="t('authority-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-authority" class="form-control"
formControlName="authority" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="invalid-uri-validation" class="invalid-feedback">
@if (formControl.errors?.invalidUri) {
<div>{{t('invalid-uri')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('clientId'); as formControl) {
<app-setting-item [title]="t('client-id-label')" [subtitle]="t('client-id-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-client-id" aria-describedby="oidc-client-id-validations" class="form-control"
formControlName="clientId" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-client-id-validations" class="invalid-feedback">
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('secret'); as formControl) {
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
<ng-template #view>
@if (formControl.value) {
{{'*'.repeat(10)}}
} @else {
{{ null | defaultValue }}
}
</ng-template>
<ng-template #edit>
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
formControlName="secret" type="password"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-secret-validations" class="invalid-feedback">
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('behavior-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('providerName'); as formControl) {
<app-setting-item [title]="t('provider-name-label')" [subtitle]="t('provider-name-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-provider-name" aria-describedby="oidc-provider-name-validations" class="form-control"
formControlName="providerName" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionAccounts'); as formControl) {
<app-setting-switch [title]="t('provision-accounts-label')" [subtitle]="t('provision-accounts-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provision-accounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
<app-setting-switch [title]="t('require-verified-email-label')" [subtitle]="t('require-verified-email-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="require-verified-email" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('syncUserSettings'); as formControl) {
<app-setting-switch [title]="t('sync-user-settings-label')" [subtitle]="t('sync-user-settings-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="sync-user-settings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('autoLogin'); as formControl) {
<app-setting-switch [title]="t('auto-login-label')" [subtitle]="t('auto-login-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="auto-login" type="checkbox" class="form-check-input" formControlName="autoLogin">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('disablePasswordAuthentication'); as formControl) {
<app-setting-switch [title]="t('disable-password-authentication-label')" [subtitle]="t('disable-password-authentication-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="disable-password-authentication" type="checkbox" class="form-check-input" formControlName="disablePasswordAuthentication">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('defaults-title')}}</h4>
<div class="text-muted">{{t('defaults-requirement')}}</div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultAgeRestriction'); as formControl) {
<app-setting-item [title]="t('default-age-restriction-label')" [subtitle]="t('default-age-restriction-tooltip')">
<ng-template #view>
<div>{{formControl.value | ageRating}}</div>
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="defaultAgeRestriction">
<option value="-1">{{t('no-restriction')}}</option>
@for (ageRating of ageRatings(); track ageRating.value) {
<option [value]="ageRating.value">{{ageRating.title}}</option>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultIncludeUnknowns'); as formControl) {
<app-setting-switch [title]="t('default-include-unknowns-label')" [subtitle]="t('default-include-unknowns-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="default-include-unknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if (oidcSettings()) {
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoles($event)" [allowAdmin]="true" [preSelectedRoles]="selectedRoles()"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibraries($event)" [preSelectedLibraries]="selectedLibraries()"></app-library-selector>
</div>
</div>
}
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('advanced-title')}}</h4>
<div class="text-muted">{{t('advanced-tooltip')}}</div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('rolesPrefix'); as formControl) {
<app-setting-item [title]="t('roles-prefix-label')" [subtitle]="t('roles-prefix-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oidc-roles-prefix" class="form-control"
formControlName="rolesPrefix" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('rolesClaim'); as formControl) {
<app-setting-item [title]="t('roles-claim-label')" [subtitle]="t('roles-claim-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oidc-roles-claim" class="form-control"
formControlName="rolesClaim" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('customScopes'); as formControl) {
<app-setting-item [title]="t('custom-scopes-label')" [subtitle]="t('custom-scopes-tooltip')">
<ng-template #view>
@let val = breakString(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>
<ng-template #edit>
<textarea rows="3" id="custom-scopes" class="form-control" formControlName="customScopes"></textarea>
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
</form>
</ng-container>

View File

@ -0,0 +1,8 @@
.invalid-feedback {
display: inherit;
}
.custom-position {
right: 5px;
top: -42px;
}

View File

@ -0,0 +1,206 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
inject,
OnInit,
signal
} from '@angular/core';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ServerSettings} from "../_models/server-settings";
import {
AbstractControl,
AsyncValidatorFn,
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";
import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {RestrictionSelectorComponent} from "../../user-settings/restriction-selector/restriction-selector.component";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {MetadataService} from "../../_services/metadata.service";
import {AgeRating} from "../../_models/metadata/age-rating";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {allRoles, Role} from "../../_services/account.service";
import {Library} from "../../_models/library/library";
import {LibraryService} from "../../_services/library.service";
import {LibrarySelectorComponent} from "../library-selector/library-selector.component";
import {RoleSelectorComponent} from "../role-selector/role-selector.component";
import {ToastrService} from "ngx-toastr";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
@Component({
selector: 'app-manage-open-idconnect',
imports: [
TranslocoDirective,
ReactiveFormsModule,
SettingItemComponent,
SettingSwitchComponent,
AgeRatingPipe,
LibrarySelectorComponent,
RoleSelectorComponent,
SafeHtmlPipe,
DefaultValuePipe,
TagBadgeComponent
],
templateUrl: './manage-open-idconnect.component.html',
styleUrl: './manage-open-idconnect.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ManageOpenIDConnectComponent implements OnInit {
private readonly settingsService = inject(SettingsService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly metadataService = inject(MetadataService);
private readonly toastr = inject(ToastrService);
serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({});
oidcSettings = signal<OidcConfig | undefined>(undefined);
ageRatings = signal<AgeRatingDto[]>([]);
selectedLibraries = signal<number[]>([]);
selectedRoles = signal<string[]>([]);
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings.set(ratings);
});
this.settingsService.getServerSettings().subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings.set(this.serverSettings.oidcConfig);
this.selectedRoles.set(this.serverSettings.oidcConfig.defaultRoles);
this.selectedLibraries.set(this.serverSettings.oidcConfig.defaultLibraries);
this.settingsForm.addControl('authority', new FormControl(this.serverSettings.oidcConfig.authority, [], [this.authorityValidator()]));
this.settingsForm.addControl('clientId', new FormControl(this.serverSettings.oidcConfig.clientId, [this.requiredIf('authority')]));
this.settingsForm.addControl('secret', new FormControl(this.serverSettings.oidcConfig.secret, [this.requiredIf('authority')]));
this.settingsForm.addControl('provisionAccounts', new FormControl(this.serverSettings.oidcConfig.provisionAccounts, []));
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.serverSettings.oidcConfig.requireVerifiedEmail, []));
this.settingsForm.addControl('syncUserSettings', new FormControl(this.serverSettings.oidcConfig.syncUserSettings, []));
this.settingsForm.addControl('rolesPrefix', new FormControl(this.serverSettings.oidcConfig.rolesPrefix, []));
this.settingsForm.addControl('rolesClaim', new FormControl(this.serverSettings.oidcConfig.rolesClaim, []));
this.settingsForm.addControl('autoLogin', new FormControl(this.serverSettings.oidcConfig.autoLogin, []));
this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.serverSettings.oidcConfig.disablePasswordAuthentication, []));
this.settingsForm.addControl('providerName', new FormControl(this.serverSettings.oidcConfig.providerName, []));
this.settingsForm.addControl("defaultAgeRestriction", new FormControl(this.serverSettings.oidcConfig.defaultAgeRestriction, []));
this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, []));
this.settingsForm.addControl('customScopes', new FormControl(this.serverSettings.oidcConfig.customScopes.join(","), []))
this.cdRef.markForCheck();
this.settingsForm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
filter(() => {
// Do not auto save when provider settings have changed
const settings: OidcConfig = this.settingsForm.getRawValue();
return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId;
}),
tap(() => this.save())
).subscribe();
}
});
}
updateRoles(roles: string[]) {
this.selectedRoles.set(roles);
this.save();
}
updateLibraries(libraries: Library[]) {
this.selectedLibraries.set(libraries.map(l => l.id));
this.save();
}
save(showConfirmation: boolean = false) {
if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings) return;
const data = this.settingsForm.getRawValue();
const newSettings = Object.assign({}, this.serverSettings);
newSettings.oidcConfig = data as OidcConfig;
newSettings.oidcConfig.defaultAgeRestriction = parseInt(newSettings.oidcConfig.defaultAgeRestriction + '', 10) as AgeRating;
newSettings.oidcConfig.defaultRoles = this.selectedRoles();
newSettings.oidcConfig.defaultLibraries = this.selectedLibraries();
newSettings.oidcConfig.customScopes = (data.customScopes as string)
.split(',').map((item: string) => item.trim())
.filter((scope: string) => scope.length > 0);
this.settingsService.updateServerSettings(newSettings).subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings.set(data.oidcConfig);
this.cdRef.markForCheck();
if (showConfirmation) {
this.toastr.success(translate('manage-oidc-connect.save-success'))
}
},
error: error => {
console.error(error);
this.toastr.error(translate('errors.generic'))
}
})
}
breakString(s: string) {
if (s) {
return s.split(',');
}
return [];
}
authorityValidator(): AsyncValidatorFn {
return (control: AbstractControl) => {
let uri: string = control.value;
if (!uri || uri.trim().length === 0) {
return of(null);
}
try {
new URL(uri);
} catch {
return of({'invalidUri': {'uri': uri}} as ValidationErrors)
}
return this.settingsService.ifValidAuthority(uri).pipe(map(ok => {
if (ok) return null;
return {'invalidUri': {'uri': uri}} as ValidationErrors;
}));
}
}
requiredIf(other: string): ValidatorFn {
return (control): ValidationErrors | null => {
const otherControl = this.settingsForm.get(other);
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}}
}
}
}

View File

@ -145,6 +145,7 @@ export class ManageSettingsComponent implements OnInit {
modelSettings.smtpConfig = this.serverSettings.smtpConfig; modelSettings.smtpConfig = this.serverSettings.smtpConfig;
modelSettings.installId = this.serverSettings.installId; modelSettings.installId = this.serverSettings.installId;
modelSettings.installVersion = this.serverSettings.installVersion; modelSettings.installVersion = this.serverSettings.installVersion;
modelSettings.oidcConfig = this.serverSettings.oidcConfig;
// Disabled FormControls are not added to the value // Disabled FormControls are not added to the value
if (this.isDocker) { if (this.isDocker) {

View File

@ -10,6 +10,7 @@
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th scope="col"></th>
<th scope="col">{{t('name-header')}}</th> <th scope="col">{{t('name-header')}}</th>
<th scope="col">{{t('last-active-header')}}</th> <th scope="col">{{t('last-active-header')}}</th>
<th scope="col">{{t('sharing-header')}}</th> <th scope="col">{{t('sharing-header')}}</th>
@ -20,6 +21,18 @@
<tbody> <tbody>
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) { @for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
<tr> <tr>
<td>
<div class="d-flex flex-row justify-content-center align-items-center">
@switch (member.identityProvider) {
@case (IdentityProvider.OpenIdConnect) {
<app-image imageUrl="assets/icons/open-id-connect-logo.svg" height="16px" width="16px"></app-image>
}
@case (IdentityProvider.Kavita) {
<app-image imageUrl="assets/icons/favicon-16x16.png" height="16px" width="16px"></app-image>
}
}
</div>
</td>
<td id="username--{{idx}}"> <td id="username--{{idx}}">
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span> <span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
@if (member.isPending) { @if (member.isPending) {

View File

@ -12,8 +12,8 @@ import {InviteUserComponent} from '../invite-user/invite-user.component';
import {EditUserComponent} from '../edit-user/edit-user.component'; import {EditUserComponent} from '../edit-user/edit-user.component';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component'; import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component';
import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common'; import {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common';
import {TranslocoModule, TranslocoService} from "@jsverse/transloco"; import {size, TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -23,6 +23,10 @@ import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {DefaultModalOptions} from "../../_models/default-modal-options"; import {DefaultModalOptions} from "../../_models/default-modal-options";
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe"; import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe"; import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
import {SettingsService} from "../settings.service";
import {ServerSettings} from "../_models/server-settings";
import {IdentityProvider} from "../../_models/user";
import {ImageComponent} from "../../shared/image/image.component";
@Component({ @Component({
selector: 'app-manage-users', selector: 'app-manage-users',
@ -31,7 +35,7 @@ import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass, imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe, DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
RoleLocalizedPipe] RoleLocalizedPipe, ImageComponent]
}) })
export class ManageUsersComponent implements OnInit { export class ManageUsersComponent implements OnInit {
@ -41,6 +45,7 @@ export class ManageUsersComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly memberService = inject(MemberService); private readonly memberService = inject(MemberService);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly settingsService = inject(SettingsService);
private readonly modalService = inject(NgbModal); private readonly modalService = inject(NgbModal);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService); private readonly confirmService = inject(ConfirmService);
@ -48,6 +53,7 @@ export class ManageUsersComponent implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
members: Member[] = []; members: Member[] = [];
settings: ServerSettings | undefined = undefined;
loggedInUsername = ''; loggedInUsername = '';
loadingMembers = false; loadingMembers = false;
libraryCount: number = 0; libraryCount: number = 0;
@ -64,6 +70,10 @@ export class ManageUsersComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.loadMembers(); this.loadMembers();
this.settingsService.getServerSettings().subscribe(settings => {
this.settings = settings;
});
} }
@ -97,8 +107,11 @@ export class ManageUsersComponent implements OnInit {
} }
openEditUser(member: Member) { openEditUser(member: Member) {
if (!this.settings) return;
const modalRef = this.modalService.open(EditUserComponent, DefaultModalOptions); const modalRef = this.modalService.open(EditUserComponent, DefaultModalOptions);
modalRef.componentInstance.member = member; modalRef.componentInstance.member.set(member);
modalRef.componentInstance.settings.set(this.settings);
modalRef.closed.subscribe(() => { modalRef.closed.subscribe(() => {
this.loadMembers(); this.loadMembers();
}); });
@ -154,4 +167,6 @@ export class ManageUsersComponent implements OnInit {
getRoles(member: Member) { getRoles(member: Member) {
return member.roles.filter(item => item != 'Pleb'); return member.roles.filter(item => item != 'Pleb');
} }
protected readonly IdentityProvider = IdentityProvider;
} }

View File

@ -3,7 +3,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter, EventEmitter,
inject, inject, input,
Input, Input,
OnInit, OnInit,
Output Output
@ -33,6 +33,7 @@ export class RoleSelectorComponent implements OnInit {
* This must have roles * This must have roles
*/ */
@Input() member: Member | undefined | User; @Input() member: Member | undefined | User;
preSelectedRoles = input<string[]>([]);
/** /**
* Allows the selection of Admin role * Allows the selection of Admin role
*/ */
@ -77,6 +78,13 @@ export class RoleSelectorComponent implements OnInit {
foundRole[0].selected = true; foundRole[0].selected = true;
} }
}); });
} else if (this.preSelectedRoles().length > 0) {
this.preSelectedRoles().forEach((role) => {
const foundRole = this.selectedRoles.filter(item => item.data === role);
if (foundRole.length > 0) {
foundRole[0].selected = true;
}
});
} else { } else {
// For new users, preselect LoginRole // For new users, preselect LoginRole
this.selectedRoles.forEach(role => { this.selectedRoles.forEach(role => {

View File

@ -1,12 +1,13 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import {computed, Injectable, signal} from '@angular/core';
import {map, of} from 'rxjs'; import {map, of, tap} from 'rxjs';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { TextResonse } from '../_types/text-response'; import { TextResonse } from '../_types/text-response';
import { ServerSettings } from './_models/server-settings'; import { ServerSettings } from './_models/server-settings';
import {MetadataSettings} from "./_models/metadata-settings"; import {MetadataSettings} from "./_models/metadata-settings";
import {MetadataMappingsExport} from "./manage-metadata-mappings/manage-metadata-mappings.component"; import {MetadataMappingsExport} from "./manage-metadata-mappings/manage-metadata-mappings.component";
import {FieldMappingsImportResult, ImportSettings} from "../_models/import-field-mappings"; import {FieldMappingsImportResult, ImportSettings} from "../_models/import-field-mappings";
import {OidcPublicConfig} from "./_models/oidc-config";
/** /**
* Used only for the Test Email Service call * Used only for the Test Email Service call
@ -30,6 +31,10 @@ export class SettingsService {
return this.http.get<ServerSettings>(this.baseUrl + 'settings'); return this.http.get<ServerSettings>(this.baseUrl + 'settings');
} }
getPublicOidcConfig() {
return this.http.get<OidcPublicConfig>(this.baseUrl + "settings/oidc");
}
getMetadataSettings() { getMetadataSettings() {
return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings'); return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings');
} }
@ -88,6 +93,11 @@ export class SettingsService {
isValidCronExpression(val: string) { isValidCronExpression(val: string) {
if (val === '' || val === undefined || val === null) return of(false); if (val === '' || val === undefined || val === null) return of(false);
return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true')); return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true'));
}
ifValidAuthority(authority: string) {
if (authority === '' || authority === undefined || authority === null) return of(false);
return this.http.post<boolean>(this.baseUrl + 'settings/is-valid-authority', {authority}, TextResonse).pipe(map(r => r + '' == 'true'));
} }
} }

View File

@ -1,7 +1,7 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef, DestroyRef, effect,
HostListener, HostListener,
inject, inject,
OnInit OnInit
@ -97,7 +97,6 @@ export class AppComponent implements OnInit {
}), takeUntilDestroyed(this.destroyRef)); }), takeUntilDestroyed(this.destroyRef));
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
} }
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
@ -118,9 +117,7 @@ export class AppComponent implements OnInit {
setCurrentUser() { setCurrentUser() {
const user = this.accountService.getUserFromLocalStorage(); const user = this.accountService.currentUserSignal();
this.accountService.setCurrentUser(user);
if (!user) return; if (!user) return;
// Bootstrap anything that's needed // Bootstrap anything that's needed

View File

@ -1,32 +1,45 @@
<ng-container *transloco="let t; read: 'login'"> <ng-container *transloco="let t; prefix: 'login'">
<app-splash-container> <app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container> <ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body> <ng-container body>
<ng-container *ngIf="isLoaded">
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
<div class="card-text">
<div class="mb-3">
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
<input class="form-control custom-input" formControlName="username" id="username" autocomplete="username"
type="text" autofocus [placeholder]="t('username')">
</div>
<div class="mb-2"> @if (isLoaded()) {
<label for="password" class="form-label visually-hidden">{{t('password')}}</label> @if (showPasswordLogin()) {
<input class="form-control custom-input" formControlName="password" name="password" autocomplete="current-password" <form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation">
id="password" type="password" [placeholder]="t('password')"> <div class="card-text">
</div> <div class="mb-3">
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
<input class="form-control custom-input" formControlName="username" id="username" autocomplete="username"
type="text" autofocus [placeholder]="t('username')">
</div>
<div class="mb-3 forgot-password"> <div class="mb-2">
<a routerLink="/registration/reset-password">{{t('forgot-password')}}</a> <label for="password" class="form-label visually-hidden">{{t('password')}}</label>
</div> <input class="form-control custom-input" formControlName="password" name="password" autocomplete="current-password"
id="password" type="password" [placeholder]="t('password')">
</div>
<div class="sign-in"> <div class="mb-3 forgot-password">
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting">{{t('submit')}}</button> <a routerLink="/registration/reset-password">{{t('forgot-password')}}</a>
</div>
<div class="sign-in">
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting()">{{t('submit')}}</button>
</div>
</div> </div>
</div> </form>
</form> }
</ng-container>
@if (showOidcButton()) {
<a
class="btn btn-outline-primary mt-2 d-flex align-items-center gap-2"
href="oidc/login"
>
<app-image height="36px" width="36px" [imageUrl]="'assets/icons/open-id-connect-logo.svg'" [styles]="{'object-fit': 'contains'}"></app-image>
{{oidcConfig()?.providerName || t('oidc')}}
</a>
}
}
</ng-container> </ng-container>
</app-splash-container> </app-splash-container>
</ng-container> </ng-container>

View File

@ -45,4 +45,8 @@ a {
.btn { .btn {
font-family: var(--login-input-font-family); font-family: var(--login-input-font-family);
} }
} }
.text-muted {
font-size: 0.8rem;
}

View File

@ -1,15 +1,24 @@
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, computed,
effect, inject,
OnInit,
signal
} from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { AccountService } from '../../_services/account.service'; import { AccountService } from '../../_services/account.service';
import { MemberService } from '../../_services/member.service'; import { MemberService } from '../../_services/member.service';
import { NavService } from '../../_services/nav.service'; import { NavService } from '../../_services/nav.service';
import { NgIf } from '@angular/common';
import { SplashContainerComponent } from '../_components/splash-container/splash-container.component'; import { SplashContainerComponent } from '../_components/splash-container/splash-container.component';
import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {environment} from "../../../environments/environment";
import {ImageComponent} from "../../shared/image/image.component";
import { SettingsService } from 'src/app/admin/settings.service';
import {OidcPublicConfig} from "../../admin/_models/oidc-config";
@Component({ @Component({
@ -17,90 +26,132 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
templateUrl: './user-login.component.html', templateUrl: './user-login.component.html',
styleUrls: ['./user-login.component.scss'], styleUrls: ['./user-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective] imports: [SplashContainerComponent, ReactiveFormsModule, RouterLink, TranslocoDirective, ImageComponent]
}) })
export class UserLoginComponent implements OnInit { export class UserLoginComponent implements OnInit {
private readonly accountService = inject(AccountService);
private readonly router = inject(Router);
private readonly memberService = inject(MemberService);
private readonly toastr = inject(ToastrService);
private readonly navService = inject(NavService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
protected readonly settingsService = inject(SettingsService);
baseUrl = environment.apiUrl;
loginForm: FormGroup = new FormGroup({ loginForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]), username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required, Validators.maxLength(256), Validators.minLength(6), Validators.pattern("^.{6,256}$")]) password: new FormControl('', [Validators.required, Validators.maxLength(256), Validators.minLength(6), Validators.pattern("^.{6,256}$")])
}); });
/**
* If there are no admins on the server, this will enable the registration to kick in.
*/
firstTimeFlow: boolean = true;
/** /**
* Used for first time the page loads to ensure no flashing * Used for first time the page loads to ensure no flashing
*/ */
isLoaded: boolean = false; isLoaded = signal(false);
isSubmitting = false; isSubmitting = signal(false);
/**
* undefined until query params are read
*/
skipAutoLogin = signal<boolean | undefined>(undefined);
/**
* Display the login form, regardless if the password authentication is disabled (admins can still log in)
* Set from query
*/
forceShowPasswordLogin = signal(false);
oidcConfig = signal<OidcPublicConfig | undefined>(undefined);
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService, /**
private toastr: ToastrService, private navService: NavService, * Display the login form
private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) { */
this.navService.hideNavBar(); showPasswordLogin = computed(() => {
this.navService.hideSideNav(); const loaded = this.isLoaded();
} const config = this.oidcConfig();
const force = this.forceShowPasswordLogin();
if (force) return true;
return loaded && config && !config.disablePasswordAuthentication;
});
showOidcButton = computed(() => {
const config = this.oidcConfig();
return config && config.enabled;
});
constructor() {
this.navService.hideNavBar();
this.navService.hideSideNav();
effect(() => {
const skipAutoLogin = this.skipAutoLogin();
const oidcConfig = this.oidcConfig();
if (!oidcConfig || skipAutoLogin === undefined) return;
if (oidcConfig.autoLogin && !skipAutoLogin) {
window.location.href = '/oidc/login';
}
});
}
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) { if (user) {
this.navService.showNavBar(); this.navService.handleLogin()
this.navService.showSideNav();
this.router.navigateByUrl('/home');
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
}); });
this.settingsService.getPublicOidcConfig().subscribe(config => {
this.oidcConfig.set(config);
});
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => { this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists; if (!adminExists) {
if (this.firstTimeFlow) {
this.router.navigateByUrl('registration/register'); this.router.navigateByUrl('registration/register');
return; return;
} }
this.isLoaded = true; this.isLoaded.set(true);
this.cdRef.markForCheck();
}); });
this.route.queryParamMap.subscribe(params => { this.route.queryParamMap.subscribe(params => {
const val = params.get('apiKey'); const val = params.get('apiKey');
if (val != null && val.length > 0) { if (val != null && val.length > 0) {
this.login(val); this.login(val);
return;
}
this.skipAutoLogin.set(params.get('skipAutoLogin') === 'true')
this.forceShowPasswordLogin.set(params.get('forceShowPassword') === 'true');
const error = params.get('error');
if (!error) return;
if (error.startsWith('errors.')) {
this.toastr.error(translate(error));
} else {
this.toastr.error(error);
} }
}); });
} }
login(apiKey: string = '') { login(apiKey: string = '') {
const model = this.loginForm.getRawValue(); const model = this.loginForm.getRawValue();
model.apiKey = apiKey; model.apiKey = apiKey;
this.isSubmitting = true; this.isSubmitting.set(true);
this.cdRef.markForCheck(); this.accountService.login(model).subscribe({
this.accountService.login(model).subscribe(() => { next: () => {
this.loginForm.reset(); this.loginForm.reset();
this.navService.showNavBar(); this.navService.handleLogin()
this.navService.showSideNav();
// Check if user came here from another url, else send to library route this.isSubmitting.set(false);
const pageResume = localStorage.getItem('kavita--auth-intersection-url'); },
if (pageResume && pageResume !== '/login') { error: (err) => {
localStorage.setItem('kavita--auth-intersection-url', ''); this.toastr.error(err.error);
this.router.navigateByUrl(pageResume); this.isSubmitting.set(false);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
} }
this.isSubmitting = false;
this.cdRef.markForCheck();
}, err => {
this.toastr.error(err.error);
this.isSubmitting = false;
this.cdRef.markForCheck();
}); });
} }
} }

View File

@ -17,6 +17,14 @@
} }
} }
@defer (when fragment === SettingsTabId.OpenIDConnect; prefetch on idle) {
@if (fragment === SettingsTabId.OpenIDConnect) {
<div class="col-xxl-6 col-12">
<app-manage-open-idconnect></app-manage-open-idconnect>
</div>
}
}
@defer (when fragment === SettingsTabId.Email; prefetch on idle) { @defer (when fragment === SettingsTabId.Email; prefetch on idle) {
@if (fragment === SettingsTabId.Email) { @if (fragment === SettingsTabId.Email) {
<div class="col-xxl-6 col-12"> <div class="col-xxl-6 col-12">

View File

@ -59,6 +59,7 @@ import {
ManagePublicMetadataSettingsComponent ManagePublicMetadataSettingsComponent
} from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component"; } from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component";
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component"; import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@ -96,6 +97,7 @@ import {ImportMappingsComponent} from "../../../admin/import-mappings/import-map
ScrobblingHoldsComponent, ScrobblingHoldsComponent,
ManageMetadataSettingsComponent, ManageMetadataSettingsComponent,
ManageReadingProfilesComponent, ManageReadingProfilesComponent,
ManageOpenIDConnectComponent,
ManagePublicMetadataSettingsComponent, ManagePublicMetadataSettingsComponent,
ImportMappingsComponent ImportMappingsComponent
], ],

View File

@ -21,6 +21,7 @@ export enum SettingsTabId {
// Admin // Admin
General = 'admin-general', General = 'admin-general',
OpenIDConnect = 'admin-oidc',
Email = 'admin-email', Email = 'admin-email',
Media = 'admin-media', Media = 'admin-media',
Users = 'admin-users', Users = 'admin-users',
@ -127,6 +128,7 @@ export class PreferenceNavComponent implements AfterViewInit {
children: [ children: [
new SideNavItem(SettingsTabId.General, [Role.Admin]), new SideNavItem(SettingsTabId.General, [Role.Admin]),
new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]), new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]),
new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]),
new SideNavItem(SettingsTabId.Media, [Role.Admin]), new SideNavItem(SettingsTabId.Media, [Role.Admin]),
new SideNavItem(SettingsTabId.Email, [Role.Admin]), new SideNavItem(SettingsTabId.Email, [Role.Admin]),
new SideNavItem(SettingsTabId.Users, [Role.Admin]), new SideNavItem(SettingsTabId.Users, [Role.Admin]),

View File

@ -259,20 +259,20 @@ export class ManageReadingProfilesComponent implements OnInit {
private packData(): ReadingProfile { private packData(): ReadingProfile {
const data: ReadingProfile = this.readingProfileForm!.getRawValue(); const data: ReadingProfile = this.readingProfileForm!.getRawValue();
data.id = this.selectedProfile!.id; data.id = this.selectedProfile!.id;
data.readingDirection = parseInt(data.readingDirection as unknown as string); data.readingDirection = parseInt(data.readingDirection + '');
data.scalingOption = parseInt(data.scalingOption as unknown as string); data.scalingOption = parseInt(data.scalingOption + '');
data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string); data.pageSplitOption = parseInt(data.pageSplitOption + '');
data.readerMode = parseInt(data.readerMode as unknown as string); data.readerMode = parseInt(data.readerMode + '');
data.layoutMode = parseInt(data.layoutMode as unknown as string); data.layoutMode = parseInt(data.layoutMode + '');
data.disableWidthOverride = parseInt(data.disableWidthOverride as unknown as string); data.disableWidthOverride = parseInt(data.disableWidthOverride + '');
data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string); data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection + '');
data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string); data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle + '');
data.bookReaderLayoutMode = parseInt(data.bookReaderLayoutMode as unknown as string); data.bookReaderLayoutMode = parseInt(data.bookReaderLayoutMode + '');
data.pdfTheme = parseInt(data.pdfTheme as unknown as string); data.pdfTheme = parseInt(data.pdfTheme + '');
data.pdfScrollMode = parseInt(data.pdfScrollMode as unknown as string); data.pdfScrollMode = parseInt(data.pdfScrollMode + '');
data.pdfSpreadMode = parseInt(data.pdfSpreadMode as unknown as string); data.pdfSpreadMode = parseInt(data.pdfSpreadMode + '');
return data; return data;
} }

View File

@ -0,0 +1 @@
<?xml version="1.0"?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg height="512px" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" width="512px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="_x32_39-openid"><g><path d="M234.849,419v6.623c-79.268-9.958-139.334-53.393-139.334-105.757 c0-39.313,33.873-73.595,84.485-92.511L178.023,180C88.892,202.497,26.001,256.607,26.001,319.866 c0,76.288,90.871,139.128,208.95,149.705l0.018-0.009V419H234.849z" style="fill:#B2B2B2;"/><polygon points="304.772,436.713 304.67,436.713 304.67,221.667 304.67,213.667 304.67,42.429 234.849,78.25 234.849,221.667 234.969,221.667 234.969,469.563 " style="fill:#F7931E;"/><path d="M485.999,291.938l-9.446-100.114l-35.938,20.331C415.087,196.649,382.5,177.5,340,177.261 l0.002,36.406v7.498c3.502,0.968,6.923,2.024,10.301,3.125c14.145,4.611,27.176,10.352,38.666,17.128l-37.786,21.254 L485.999,291.938z" style="fill:#B2B2B2;"/></g></g><g id="Layer_1"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -5,7 +5,69 @@
"password": "{{common.password}}", "password": "{{common.password}}",
"password-validation": "{{validation.password-validation}}", "password-validation": "{{validation.password-validation}}",
"forgot-password": "Forgot Password?", "forgot-password": "Forgot Password?",
"submit": "Sign in" "submit": "Sign in",
"oidc": "OpenID Connect"
},
"oidc": {
"title": "OIDC Callback",
"login": "Back to login screen",
"error-loading-info": "An error occurred loading OIDC info, contact your administrator",
"timeout": "OIDC resolution has timed out or an error has occurred"
},
"manage-oidc-connect": {
"save": "{{common.save}}",
"save-success": "Successfully updated your OIDC settings",
"notice": "Notice",
"restart-required": "Changing Authority or Client ID requires a manual restart of Kavita to take effect.",
"provider-title": "Provider",
"provider-tooltip": "Provider settings require you to manually click Save. Kavita must be configured as a confidential client and needs a redirect URL. See the <a href='https://wiki.kavitareader.com/guides/admin-settings/open-id-connect/' target='_blank' rel='noreferrer noopener'>wiki</a> for more details.",
"behavior-title": "Behavior",
"other-field-required": "{{validation.other-field-required}}",
"invalid-uri": "{{validation.invalid-uri}}",
"manual-save-label": "Changing provider settings requires a manual save",
"authority-label": "Authority",
"authority-tooltip": "The URL to your OIDC provider",
"client-id-label": "Client ID",
"client-id-tooltip": "The ClientID set in your OIDC provider, can be anything",
"secret-label": "Client secret",
"secret-tooltip": "The secret as generated by your OIDC provider",
"provision-accounts-label": "Provision accounts",
"provision-accounts-tooltip": "Auto-create a new account when logging in via OIDC if it can't be matched to an existing one",
"require-verified-email-label": "Require verified emails",
"require-verified-email-tooltip": "Requires email verification when creating a new account or matching with an existing one. If a newly created account has a verified email, it will be automatically verified on Kavita's side.",
"sync-user-settings-label": "Sync user settings with OIDC roles",
"sync-user-settings-tooltip": "Users created via OIDC will be fully managed by the identity provider, including roles, library access, and age rating. If this option is disabled, users will not have access to any content after account creation. Refer to the documentation for more details.",
"auto-login-label": "Auto login",
"auto-login-tooltip": "Auto redirect to OIDC provider when opening the login screen",
"disable-password-authentication-label": "Disable password authentication",
"disable-password-authentication-tooltip": "Users with the admin role can bypass this restriction",
"provider-name-label": "Provider name",
"provider-name-tooltip": "Name shown on the login screen",
"defaults-title": "Defaults",
"defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off",
"default-include-unknowns-label": "Include unknowns",
"default-include-unknowns-tooltip": "Include unknown age ratings",
"default-age-restriction-label": "Age rating",
"default-age-restriction-tooltip": "Maximum age rating shown to new users",
"no-restriction": "{{restriction-selector.no-restriction}}",
"advanced-title": "Advanced settings",
"advanced-tooltip": "Only modify these options if you understand their impact.",
"roles-prefix-label": "Roles prefix",
"roles-prefix-tooltip": "Kavita will only consider roles that start with this prefix.",
"roles-claim-label": "Roles claim",
"roles-claim-tooltip": "The claim Kavita will use to extract roles. Leave blank to use the default.",
"custom-scopes-label": "Custom scopes",
"custom-scopes-tooltip": "Additional scopes to request from your OIDC provider during login. Separate multiple scopes with commas. Incorrect values may prevent login."
},
"identity-provider-pipe": {
"kavita": "Kavita",
"oidc": "OpenID Connect"
}, },
"dashboard": { "dashboard": {
@ -28,7 +90,12 @@
"cancel": "{{common.cancel}}", "cancel": "{{common.cancel}}",
"saving": "Saving…", "saving": "Saving…",
"update": "Update", "update": "Update",
"account-detail-title": "Account Details" "account-detail-title": "Account Details",
"notice": "Warning!",
"out-of-sync": "This user was created via OIDC. If the SyncUsers setting is enabled, any changes made to this account may be overwritten.",
"oidc-managed": "This user is managed via OIDC. Please contact your OIDC administrator to request any changes.",
"identity-provider": "Identity provider",
"identity-provider-tooltip": "Kavita users are never synced with OIDC accounts."
}, },
"user-scrobble-history": { "user-scrobble-history": {
@ -1714,6 +1781,7 @@
"import-section-title": "Import", "import-section-title": "Import",
"kavitaplus-section-title": "Kavita+", "kavitaplus-section-title": "Kavita+",
"admin-general": "General", "admin-general": "General",
"admin-oidc": "OpenID Connect",
"admin-users": "Users", "admin-users": "Users",
"admin-libraries": "Libraries", "admin-libraries": "Libraries",
"admin-media": "Media", "admin-media": "Media",
@ -2331,7 +2399,7 @@
"asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}", "asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}",
"invalid-asin": "ASIN must be a valid ISBN-10 or ISBN-13 format", "invalid-asin": "ASIN must be a valid ISBN-10 or ISBN-13 format",
"description-label": "Description", "description-label": "Description",
"required-field": "{{validations.required-field}}", "required-field": "{{validation.required-field}}",
"cover-image-description": "{{edit-series-modal.cover-image-description}}", "cover-image-description": "{{edit-series-modal.cover-image-description}}",
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.", "cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
"save": "{{common.save}}", "save": "{{common.save}}",
@ -2483,6 +2551,19 @@
"import-fields": { "import-fields": {
"non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file", "non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file",
"non-unique-fields": "Field mappings do not have a unique id, please correct your import file" "non-unique-fields": "Field mappings do not have a unique id, please correct your import file"
},
"oidc": {
"missing-external-id": "OIDC did not return a valid identifier",
"missing-email": "OIDC did not return a valid email",
"email-not-verified": "Your email must be verified to allow login via OIDC",
"no-account": "No matching account found",
"disabled-account": "This account is disabled, please contact an administrator",
"creating-user": "Failed to create a new user, please contact an administrator",
"role-not-assigned": "You do not have the required roles assigned to access this application",
"failed-to-update-email": "Failed to update email",
"failed-to-update-username": "Failed to update username",
"email-in-use": "Email already in use by another account",
"syncing-user": "Failed to sync your account from OIDC, please contact an administrator"
} }
}, },
@ -3038,9 +3119,11 @@
"validation": { "validation": {
"required-field": "This field is required", "required-field": "This field is required",
"other-field-required": "{{name}} is required when {{other}} is set",
"valid-email": "This must be a valid email", "valid-email": "This must be a valid email",
"password-validation": "Password must be between 6 and 256 characters in length", "password-validation": "Password must be between 6 and 256 characters in length",
"year-validation": "This must be a valid year greater than 1000 and 4 characters long" "year-validation": "This must be a valid year greater than 1000 and 4 characters long",
"invalid-uri": "The provided uri is invalid"
}, },
"entity-type": { "entity-type": {

View File

@ -6,8 +6,8 @@ const IP = 'localhost';
export const environment = { export const environment = {
production: false, production: false,
apiUrl: 'http://' + IP + ':5000/api/', apiUrl: 'http://' + IP + ':4200/api/',
hubUrl: 'http://'+ IP + ':5000/hubs/', hubUrl: 'http://'+ IP + ':4200/hubs/',
buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL', buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL',
manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss' manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss'
}; };

View File

@ -1,52 +1,27 @@
/// <reference types="@angular/localize" /> /// <reference types="@angular/localize" />
import {APP_INITIALIZER, ApplicationConfig, importProvidersFrom,} from '@angular/core'; import {ApplicationConfig, importProvidersFrom, inject, provideAppInitializer,} from '@angular/core';
import {AppComponent} from './app/app.component'; import {AppComponent} from './app/app.component';
import {NgCircleProgressModule} from 'ng-circle-progress'; import {NgCircleProgressModule} from 'ng-circle-progress';
import {ToastrModule} from 'ngx-toastr'; import {ToastrModule, ToastrService} from 'ngx-toastr';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {AppRoutingModule} from './app/app-routing.module'; import {AppRoutingModule} from './app/app-routing.module';
import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser'; import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser';
import {JwtInterceptor} from './app/_interceptors/jwt.interceptor'; import {JwtInterceptor} from './app/_interceptors/jwt.interceptor';
import {ErrorInterceptor} from './app/_interceptors/error.interceptor'; import {ErrorInterceptor} from './app/_interceptors/error.interceptor';
import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
import {provideTransloco, TranslocoConfig, TranslocoService} from "@jsverse/transloco"; import {provideTransloco, translate, TranslocoConfig, TranslocoService} from "@jsverse/transloco";
import {environment} from "./environments/environment"; import {environment} from "./environments/environment";
import {AccountService} from "./app/_services/account.service"; import {AccountService} from "./app/_services/account.service";
import {switchMap} from "rxjs"; import {catchError, filter, firstValueFrom, Observable, of, switchMap, take, tap, timeout} from "rxjs";
import {provideTranslocoLocale} from "@jsverse/transloco-locale"; import {provideTranslocoLocale} from "@jsverse/transloco-locale";
import {LazyLoadImageModule} from "ng-lazyload-image"; import {LazyLoadImageModule} from "ng-lazyload-image";
import {getSaver, SAVER} from "./app/_providers/saver.provider"; import {getSaver, SAVER} from "./app/_providers/saver.provider";
import {distinctUntilChanged} from "rxjs/operators";
import {APP_BASE_HREF, PlatformLocation} from "@angular/common"; import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations'; import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
import {HttpLoader} from "./httpLoader"; import {HttpLoader} from "./httpLoader";
import {SettingsService} from "./app/admin/settings.service";
const disableAnimations = !('animate' in document.documentElement); const disableAnimations = !('animate' in document.documentElement);
export function preloadUser(userService: AccountService, transloco: TranslocoService) {
return function() {
return userService.currentUser$.pipe(distinctUntilChanged(), switchMap((user) => {
if (user && user.preferences.locale) {
transloco.setActiveLang(user.preferences.locale);
return transloco.load(user.preferences.locale)
}
// If no user or locale is available, fallback to the default language ('en')
const localStorageLocale = localStorage.getItem(AccountService.localeKey) || 'en';
transloco.setActiveLang(localStorageLocale);
return transloco.load(localStorageLocale);
})).subscribe();
};
}
export const preLoad = {
provide: APP_INITIALIZER,
multi: true,
useFactory: preloadUser,
deps: [AccountService, TranslocoService]
};
function transformLanguageCodes(arr: Array<string>) { function transformLanguageCodes(arr: Array<string>) {
const transformedArray: Array<string> = []; const transformedArray: Array<string> = [];
@ -112,6 +87,34 @@ function getBaseHref(platformLocation: PlatformLocation): string {
return platformLocation.getBaseHrefFromDOM(); return platformLocation.getBaseHrefFromDOM();
} }
function loadUserLocale(transloco: TranslocoService, accountService: AccountService) {
const user = accountService.currentUserSignal();
const locale = user?.preferences?.locale || localStorage.getItem(AccountService.localeKey) || 'en';
transloco.setActiveLang(locale);
return transloco.load(locale);
}
/**
* Setup user from localstorage
*/
function bootstrapUser() {
const accountService = inject(AccountService);
const transloco = inject(TranslocoService);
return firstValueFrom(accountService.isOidcAuthenticated().pipe(
switchMap((isOidc)=> isOidc ? accountService.getAccount() : of(null)),
catchError(() => of(null)),
tap(user => {
if (!user) {
accountService.setCurrentUser(accountService.getUserFromLocalStorage());
}
}),
switchMap(() => loadUserLocale(transloco, accountService)),
));
}
bootstrapApplication(AppComponent, { bootstrapApplication(AppComponent, {
providers: [ providers: [
importProvidersFrom(BrowserModule, importProvidersFrom(BrowserModule,
@ -138,7 +141,6 @@ bootstrapApplication(AppComponent, {
}), }),
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
preLoad,
Title, Title,
{ provide: SAVER, useFactory: getSaver }, { provide: SAVER, useFactory: getSaver },
{ {
@ -146,7 +148,8 @@ bootstrapApplication(AppComponent, {
useFactory: getBaseHref, useFactory: getBaseHref,
deps: [PlatformLocation] deps: [PlatformLocation]
}, },
provideHttpClient(withInterceptorsFromDi()) provideHttpClient(withInterceptorsFromDi()),
provideAppInitializer(() => bootstrapUser()),
] ]
} as ApplicationConfig) } as ApplicationConfig)
.catch(err => console.error(err)); .catch(err => console.error(err));