mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-05-24 00:22:31 -04:00
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:
@@ -1,19 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
@@ -24,25 +27,56 @@ public interface IAccountService
|
||||
{
|
||||
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
|
||||
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<bool> HasBookmarkPermission(AppUser? user);
|
||||
Task<bool> HasDownloadPermission(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 ILogger<AccountService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
|
||||
public static readonly Regex AllowedUsernameRegex = AllowedUsernameRegexAttr();
|
||||
|
||||
public AccountService(UserManager<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;
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
|
||||
@@ -77,8 +111,13 @@ public class AccountService : IAccountService
|
||||
|
||||
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
|
||||
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null
|
||||
&& x.NormalizedUserName == username.ToUpper()))
|
||||
@@ -143,4 +182,113 @@ public class AccountService : IAccountService
|
||||
|
||||
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
public async Task<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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
@@ -13,13 +15,13 @@ using API.Entities.MetadataMatching;
|
||||
using API.Extensions;
|
||||
using API.Logging;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using Flurl.Http;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharpCompress.Common;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
@@ -35,6 +37,12 @@ public interface ISettingsService
|
||||
/// <returns></returns>
|
||||
Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings);
|
||||
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 ITaskScheduler _taskScheduler;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
private readonly IOidcService _oidcService;
|
||||
|
||||
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
|
||||
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
|
||||
ILogger<SettingsService> logger)
|
||||
ILogger<SettingsService> logger, IOidcService oidcService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
_libraryWatcher = libraryWatcher;
|
||||
_taskScheduler = taskScheduler;
|
||||
_logger = logger;
|
||||
_oidcService = oidcService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -292,6 +302,7 @@ public class SettingsService : ISettingsService
|
||||
}
|
||||
|
||||
var updateTask = false;
|
||||
var updatedOidcSettings = false;
|
||||
foreach (var setting in currentSettings)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
|
||||
@@ -329,7 +340,7 @@ public class SettingsService : ISettingsService
|
||||
updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
|
||||
|
||||
UpdateEmailSettings(setting, updateSettingsDto);
|
||||
|
||||
updatedOidcSettings = await UpdateOidcSettings(setting, updateSettingsDto) || updatedOidcSettings;
|
||||
|
||||
|
||||
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
|
||||
@@ -481,6 +492,17 @@ public class SettingsService : ISettingsService
|
||||
BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks());
|
||||
}
|
||||
|
||||
if (updatedOidcSettings)
|
||||
{
|
||||
Configuration.OidcSettings = new Configuration.OpenIdConnectSettings
|
||||
{
|
||||
Authority = updateSettingsDto.OidcConfig.Authority,
|
||||
ClientId = updateSettingsDto.OidcConfig.ClientId,
|
||||
Secret = updateSettingsDto.OidcConfig.Secret,
|
||||
CustomScopes = updateSettingsDto.OidcConfig.CustomScopes,
|
||||
};
|
||||
}
|
||||
|
||||
if (updateSettingsDto.EnableFolderWatching)
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching());
|
||||
@@ -503,6 +525,29 @@ public class SettingsService : ISettingsService
|
||||
return updateSettingsDto;
|
||||
}
|
||||
|
||||
public async Task<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)
|
||||
{
|
||||
_directoryService.ExistOrCreate(bookmarkDirectory);
|
||||
@@ -536,6 +581,45 @@ public class SettingsService : ISettingsService
|
||||
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)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.EmailHost &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,8 @@ public class StatsService : IStatsService
|
||||
DotnetVersion = Environment.Version.ToString(),
|
||||
OpdsEnabled = serverSettings.EnableOpds,
|
||||
EncodeMediaAs = serverSettings.EncodeMediaAs,
|
||||
MatchedMetadataEnabled = mediaSettings.Enabled
|
||||
MatchedMetadataEnabled = mediaSettings.Enabled,
|
||||
OidcEnabled = !string.IsNullOrEmpty(serverSettings.OidcConfig.Authority),
|
||||
};
|
||||
|
||||
dto.OsLocale = CultureInfo.CurrentCulture.EnglishName;
|
||||
@@ -308,6 +309,7 @@ public class StatsService : IStatsService
|
||||
libDto.UsingFolderWatching = library.FolderWatching;
|
||||
libDto.CreateCollectionsFromMetadata = library.ManageCollections;
|
||||
libDto.CreateReadingListsFromMetadata = library.ManageReadingLists;
|
||||
libDto.EnabledMetadata = library.EnableMetadata;
|
||||
libDto.LibraryType = library.Type;
|
||||
|
||||
dto.Libraries.Add(libDto);
|
||||
@@ -353,7 +355,9 @@ public class StatsService : IStatsService
|
||||
userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList();
|
||||
userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count;
|
||||
userDto.SmartFilterCreatedCount = user.SmartFilters.Count;
|
||||
userDto.IsSharingReviews = user.UserPreferences.ShareReviews;
|
||||
userDto.WantToReadSeriesCount = user.WantToRead.Count;
|
||||
userDto.IdentityProvider = user.IdentityProvider;
|
||||
|
||||
if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user