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
80 changed files with 7604 additions and 279 deletions
+155 -7
View File
@@ -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();
}
+662
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;
}
}
+88 -4
View File
@@ -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 &&
+59
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;
}
}
+5 -1
View File
@@ -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))
{