Cleanup and some edge case fixes

This commit is contained in:
Amelia 2025-07-06 16:42:21 +02:00
parent b6bfc65bc4
commit 6e72c74fde
17 changed files with 172 additions and 148 deletions

View file

@ -77,14 +77,24 @@ public class AccountController : BaseApiController
_oidcService = oidcService; _oidcService = oidcService;
} }
/// <summary>
/// Returns the current user, as it would from login
/// </summary>
/// <returns></returns>
/// <exception cref="UnauthorizedAccessException"></exception>
/// <remarks>Also throws UnauthorizedAccessException if the users is missing the Login role</remarks>
/// <remarks>Syncs Oidc settings if enabled, and user is Oidc owned</remarks>
[HttpGet] [HttpGet]
public async Task<ActionResult<UserDto>> GetCurrentUserAsync() public async Task<ActionResult<UserDto>> GetCurrentUserAsync()
{ {
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user == null) throw new UnauthorizedAccessException(); if (user == null) throw new UnauthorizedAccessException();
if (user.Owner == AppUserOwner.OpenIdConnect)
{
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
await _oidcService.SyncUserSettings(oidcSettings, User, user); await _oidcService.SyncUserSettings(oidcSettings, User, user);
}
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"));
@ -169,10 +179,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"));
@ -243,7 +253,7 @@ public class AccountController : BaseApiController
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; var oidcConfig = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
// Setting only takes effect if OIDC is funcitonal, and if we're not logging in via ApiKey // Setting only takes effect if OIDC is functional, and if we're not logging in via ApiKey
var disablePasswordAuthentication = oidcConfig is {Enabled: true, DisablePasswordAuthentication: true} && string.IsNullOrEmpty(loginDto.ApiKey); 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 (disablePasswordAuthentication && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "password-authentication-disabled"));
@ -545,19 +555,24 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); 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"));
// Disallowed editing users synced via OIDC // Disallowed editing users owned by OIDC
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
if (user.Owner == AppUserOwner.OpenIdConnect && if (user.Owner == AppUserOwner.OpenIdConnect && dto.Owner != AppUserOwner.Native && oidcSettings.SyncUserSettings)
dto.Owner != AppUserOwner.Native &&
oidcSettings.SyncUserSettings)
{ {
return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed"));
} }
var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser(); var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser();
if (user.Id != defaultAdminUser.Id) if (user.Id == defaultAdminUser.Id && dto.Owner != AppUserOwner.Native)
{ {
user.Owner = dto.Owner; return BadRequest(await _localizationService.Translate(User.GetUserId(), "cannot-change-ownership-original-user"));
}
if (user.Owner == AppUserOwner.OpenIdConnect)
{
// Do not change any other fields when the user is owned by OIDC
await _unitOfWork.CommitAsync();
return Ok();
} }
// Check if username is changing // Check if username is changing
@ -713,10 +728,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;
@ -815,29 +830,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

@ -13,6 +13,10 @@ public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWo
IMapper mapper, ISettingsService settingsService): BaseApiController IMapper mapper, ISettingsService settingsService): BaseApiController
{ {
/// <summary>
/// Retrieve publicly required configuration regarding Oidc
/// </summary>
/// <returns></returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("config")] [HttpGet("config")]
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig() public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
@ -21,6 +25,11 @@ public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWo
return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig)); return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig));
} }
/// <summary>
/// Validate if the given authority is reachable from the server
/// </summary>
/// <param name="authority"></param>
/// <returns></returns>
[Authorize("RequireAdminRole")] [Authorize("RequireAdminRole")]
[HttpPost("is-valid-authority")] [HttpPost("is-valid-authority")]
public async Task<ActionResult<bool>> IsValidAuthority([FromBody] IsValidAuthorityBody authority) public async Task<ActionResult<bool>> IsValidAuthority([FromBody] IsValidAuthorityBody authority)

View file

@ -10,7 +10,7 @@ public record OidcPublicConfigDto
/// <inheritdoc cref="ServerSettingKey.OidcClientId"/> /// <inheritdoc cref="ServerSettingKey.OidcClientId"/>
public string? ClientId { get; set; } public string? ClientId { get; set; }
/// <summary> /// <summary>
/// Optional OpenID Connect ClientSecret, required if authority is set /// Automatically redirect to the Oidc login screen
/// </summary> /// </summary>
public bool AutoLogin { get; set; } public bool AutoLogin { get; set; }
/// <summary> /// <summary>

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
@ -68,4 +69,9 @@ public static class EnumerableExtensions
return q; return q;
} }
public static string AsJoinedString(this IEnumerable<IdentityError> errors)
{
return string.Join(",", errors.Select(e => e.Description));
}
} }

View file

@ -146,14 +146,10 @@ public static class IdentityServiceExtensions
}); });
services.AddAuthorization(opt => services.AddAuthorizationBuilder()
{ .AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole))
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); .AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole))
opt.AddPolicy("RequireDownloadRole", .AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
opt.AddPolicy("RequireChangePasswordRole",
policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
});
return services; return services;
} }
@ -163,7 +159,6 @@ public static class IdentityServiceExtensions
if (ctx.Principal == null) return; if (ctx.Principal == null) return;
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>(); var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService<IUnitOfWork>();
var user = await oidcService.LoginOrCreate(ctx.Principal); var user = await oidcService.LoginOrCreate(ctx.Principal);
if (user == null) if (user == null)
{ {
@ -180,6 +175,7 @@ public static class IdentityServiceExtensions
new(ClaimTypes.Name, user.UserName ?? string.Empty), new(ClaimTypes.Name, user.UserName ?? string.Empty),
}; };
var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService<IUnitOfWork>();
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings) if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings)
{ {

View file

@ -19,6 +19,7 @@
"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": "This user is managed by OIDC, cannot edit", "oidc-managed": "This user is managed by OIDC, cannot edit",
"cannot-change-ownership-original-user": "Ownership of the original admin account cannot be changed",
"username-taken": "Username already taken", "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",

View file

@ -2,19 +2,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
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.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;
@ -41,6 +42,8 @@ public interface IAccountService
/// <remarks>Ensure that the users SideNavStreams are loaded</remarks> /// <remarks>Ensure that the users SideNavStreams are loaded</remarks>
Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole); Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole);
Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles); Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles);
void AddDefaultStreamsToUser(AppUser user);
Task AddDefaultReadingProfileToUser(AppUser user);
} }
public class AccountService : IAccountService public class AccountService : IAccountService
@ -48,13 +51,16 @@ public class AccountService : IAccountService
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 AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork) public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork,
IMapper mapper)
{ {
_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)
@ -158,11 +164,11 @@ public class AccountService : IAccountService
public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole) public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole)
{ {
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList();
List<Library> libraries; List<Library> libraries;
if (hasAdminRole) if (hasAdminRole)
{ {
_logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", _logger.LogInformation("{UserName} is admin. Granting access to all libraries",
user.UserName); user.UserName);
libraries = allLibraries; libraries = allLibraries;
} }
@ -176,7 +182,7 @@ public class AccountService : IAccountService
user.RemoveSideNavFromLibrary(lib); user.RemoveSideNavFromLibrary(lib);
} }
libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(librariesIds, LibraryIncludes.AppUser)).ToList(); libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList();
} }
foreach (var lib in libraries) foreach (var lib in libraries)
@ -207,4 +213,27 @@ public class AccountService : IAccountService
return []; 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();
}
} }

View file

@ -12,7 +12,6 @@ using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers.Builders; using API.Helpers.Builders;
using AutoMapper;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -43,7 +42,7 @@ public interface IOidcService
} }
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager, public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService IUnitOfWork unitOfWork, IAccountService accountService): IOidcService
{ {
private const string LibraryAccessPrefix = "library-"; private const string LibraryAccessPrefix = "library-";
private const string AgeRatingPrefix = "age-rating-"; private const string AgeRatingPrefix = "age-rating-";
@ -71,7 +70,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user != null) if (user != null)
{ {
logger.LogInformation("User {Name} has matched on email to {ExternalId}", user.UserName, externalId); logger.LogInformation("User {UserName} has matched on email to {ExternalId}", user.Id, externalId);
user.ExternalId = externalId; user.ExternalId = externalId;
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
return user; return user;
@ -85,7 +84,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
if (user == null) return null; if (user == null) return null;
var roles = await userManager.GetRolesAsync(user); var roles = await userManager.GetRolesAsync(user);
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) if (roles.Count == 0 || !roles.Contains(PolicyConstants.LoginRole))
throw new KavitaException("errors.oidc.disabled-account"); throw new KavitaException("errors.oidc.disabled-account");
return user; return user;
@ -102,6 +101,30 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
} }
private async Task<string?> FindBestAvailableName(ClaimsPrincipal claimsPrincipal)
{
var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername);
if (await IsNameAvailable(name)) return name;
name = claimsPrincipal.FindFirstValue(ClaimTypes.Name);
if (await IsNameAvailable(name)) return name;
name = claimsPrincipal.FindFirstValue(ClaimTypes.GivenName);
if (await IsNameAvailable(name)) return name;
name = claimsPrincipal.FindFirstValue(ClaimTypes.Surname);
if (await IsNameAvailable(name)) return name;
return null;
}
private async Task<bool> IsNameAvailable(string? name)
{
if (string.IsNullOrEmpty(name)) return false;
return await userManager.FindByNameAsync(name) == null;
}
private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId) private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId)
{ {
if (!settings.ProvisionAccounts) return null; if (!settings.ProvisionAccounts) return null;
@ -109,20 +132,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null; if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null;
// TODO?: Try one by one, for more chance of a nicer username var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value;
var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername);
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Name);
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName);
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname);
name ??= emailClaim.Value;
var other = await userManager.FindByNameAsync(name);
if (other != null)
{
// We match by email, so this will always be unique
name = emailClaim.Value;
}
logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId); logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId);
// TODO: Move to account service, as we're sharing code with AccountController // TODO: Move to account service, as we're sharing code with AccountController
@ -147,8 +157,8 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.ExternalId = externalId; user.ExternalId = externalId;
user.Owner = AppUserOwner.OpenIdConnect; user.Owner = AppUserOwner.OpenIdConnect;
AddDefaultStreamsToUser(user, mapper); accountService.AddDefaultStreamsToUser(user);
await AddDefaultReadingProfileToUser(user); await accountService.AddDefaultReadingProfileToUser(user);
await SyncUserSettings(settings, claimsPrincipal, user); await SyncUserSettings(settings, claimsPrincipal, user);
await SetDefaults(settings, user); await SetDefaults(settings, user);
@ -161,6 +171,9 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
{ {
if (settings.SyncUserSettings) return; if (settings.SyncUserSettings) return;
logger.LogDebug("Assigning defaults to newly created user; Roles: {Roles}, Libraries: {Libraries}, AgeRating: {AgeRating}, IncludeUnknowns: {IncludeUnknowns}",
settings.DefaultRoles, settings.DefaultLibraries, settings.DefaultAgeRating, settings.DefaultIncludeUnknowns);
// Assign roles // Assign roles
var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles); var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles);
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
@ -179,13 +192,14 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
{ {
if (!settings.SyncUserSettings) return; if (!settings.SyncUserSettings) return;
// Never sync the default user
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
if (defaultAdminUser.Id == user.Id) return; if (defaultAdminUser.Id == user.Id) return;
logger.LogDebug("Syncing user {UserId} from OIDC", user.Id); logger.LogInformation("Syncing user {UserName} from OIDC", user.UserName);
await SyncRoles(claimsPrincipal, user); await SyncRoles(claimsPrincipal, user);
await SyncLibraries(claimsPrincipal, user); await SyncLibraries(claimsPrincipal, user);
SyncAgeRestriction(claimsPrincipal, user); await SyncAgeRestriction(claimsPrincipal, user);
if (unitOfWork.HasChanges()) if (unitOfWork.HasChanges())
@ -195,39 +209,45 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user) private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
{ {
var roles = claimsPrincipal.GetAccessRoles(); var roles = claimsPrincipal.GetAccessRoles();
logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles); logger.LogDebug("Syncing access roles for user {UserName}, found roles {Roles}", user.UserName, roles);
var errors = await accountService.UpdateRolesForUser(user, roles); var errors = await accountService.UpdateRolesForUser(user, roles);
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
} }
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
{ {
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
var libraryAccess = claimsPrincipal var libraryAccess = claimsPrincipal
.FindAll(ClaimTypes.Role) .FindAll(ClaimTypes.Role)
.Where(r => r.Value.StartsWith(LibraryAccessPrefix)) .Where(r => r.Value.StartsWith(LibraryAccessPrefix))
.Select(r => r.Value.TrimPrefix(LibraryAccessPrefix)) .Select(r => r.Value.TrimPrefix(LibraryAccessPrefix))
.ToList(); .ToList();
logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess);
if (libraryAccess.Count == 0 && !hasAdminRole) return; logger.LogDebug("Syncing libraries for user {UserName}, found library roles {Roles}", user.UserName, libraryAccess);
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList(); var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList();
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
} }
private void SyncAgeRestriction(ClaimsPrincipal claimsPrincipal, AppUser user) private async Task SyncAgeRestriction(ClaimsPrincipal claimsPrincipal, AppUser user)
{ {
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
logger.LogInformation("User {UserName} is admin, granting access to all age ratings", user.UserName);
user.AgeRestriction = AgeRating.NotApplicable;
user.AgeRestrictionIncludeUnknowns = true;
return;
}
var ageRatings = claimsPrincipal var ageRatings = claimsPrincipal
.FindAll(ClaimTypes.Role) .FindAll(ClaimTypes.Role)
.Where(r => r.Value.StartsWith(AgeRatingPrefix)) .Where(r => r.Value.StartsWith(AgeRatingPrefix))
.Select(r => r.Value.TrimPrefix(AgeRatingPrefix)) .Select(r => r.Value.TrimPrefix(AgeRatingPrefix))
.ToList(); .ToList();
logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings); logger.LogDebug("Syncing age restriction for user {UserName}, found restrictions {Restrictions}", user.UserName, ageRatings);
if (ageRatings.Count == 0) return;
var highestAgeRating = AgeRating.Unknown; var highestAgeRating = AgeRating.Unknown;
@ -243,29 +263,9 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.AgeRestriction = highestAgeRating; user.AgeRestriction = highestAgeRating;
user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns); user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns);
logger.LogDebug("Synced age restriction for user {UserName}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}",
user.UserName, ageRatings, user.AgeRestrictionIncludeUnknowns);
} }
// DUPLICATED CODE
private static void AddDefaultStreamsToUser(AppUser user, IMapper mapper)
{
foreach (var newStream in Seed.DefaultStreams.Select(mapper.Map<AppUserDashboardStream, AppUserDashboardStream>))
{
user.DashboardStreams.Add(newStream);
}
foreach (var stream in Seed.DefaultSideNavStreams.Select(mapper.Map<AppUserSideNavStream, AppUserSideNavStream>))
{
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();
}
} }

View file

@ -16,7 +16,6 @@ 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 Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
@ -367,10 +366,9 @@ public class SettingsService : ISettingsService
var url = authority + "/.well-known/openid-configuration"; var url = authority + "/.well-known/openid-configuration";
try try
{ {
//await url.GetJsonAsync<OpenIdConnectConfiguration>(); var json = await url.GetStringAsync();
//return true; var config = OpenIdConnectConfiguration.Create(json);
var res = await url.GetAsync(); return config.Issuer == Configuration.OidcAuthority;
return res.StatusCode == 200;
} }
catch (Exception e) catch (Exception e)
{ {
@ -420,9 +418,11 @@ public class SettingsService : ISettingsService
{ {
throw new KavitaException("oidc-invalid-authority"); throw new KavitaException("oidc-invalid-authority");
} }
setting.Value = updateSettingsDto.OidcConfig.Authority; setting.Value = updateSettingsDto.OidcConfig.Authority;
Configuration.OidcAuthority = updateSettingsDto.OidcConfig.Authority; Configuration.OidcAuthority = updateSettingsDto.OidcConfig.Authority;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
_logger.LogWarning("OIDC Authority is changing, clearing all external ids"); _logger.LogWarning("OIDC Authority is changing, clearing all external ids");
await _oidcService.ClearOidcIds(); await _oidcService.ClearOidcIds();
return; return;
@ -441,7 +441,7 @@ public class SettingsService : ISettingsService
var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig);
if (setting.Value == newValue) return; if (setting.Value == newValue) return;
setting.Value = JsonSerializer.Serialize(updateSettingsDto.OidcConfig); setting.Value = newValue;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }

View file

@ -6,7 +6,7 @@ export interface User {
username: string; username: string;
// This is set by the oidc service, will always take precedence over the Kavita generated token // This is set by the oidc service, will always take precedence over the Kavita generated token
// When set, the refresh logic for the Kavita token will not run // When set, the refresh logic for the Kavita token will not run
oidcToken: string; oidcToken?: string;
token: string; token: string;
refreshToken: string; refreshToken: string;
roles: string[]; roles: string[];

View file

@ -92,10 +92,6 @@ export class AccountService {
}); });
} }
oidcEnabled() {
return this.httpClient.get<boolean>(this.baseUrl + "oidc/enabled");
}
canInvokeAction(user: User, action: Action) { canInvokeAction(user: User, action: Action) {
const isAdmin = this.hasAdminRole(user); const isAdmin = this.hasAdminRole(user);
const canDownload = this.hasDownloadRole(user); const canDownload = this.hasDownloadRole(user);

View file

@ -10,6 +10,7 @@ import {take} from "rxjs/operators";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {APP_BASE_HREF} from "@angular/common"; import {APP_BASE_HREF} from "@angular/common";
import {MessageHubService} from "./message-hub.service";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -21,6 +22,7 @@ export class OidcService {
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly toastR = inject(ToastrService); private readonly toastR = inject(ToastrService);
private readonly messageHub = inject(MessageHubService);
protected readonly baseUrl = inject(APP_BASE_HREF); protected readonly baseUrl = inject(APP_BASE_HREF);
apiBaseUrl = environment.apiUrl; apiBaseUrl = environment.apiUrl;
@ -33,7 +35,7 @@ export class OidcService {
public readonly loaded$ = toObservable(this.loaded); public readonly loaded$ = toObservable(this.loaded);
/** /**
* OIDC discovery document has been loaded, and login tried and OIDC has been set up * OIDC discovery document has been loaded, login tried and OIDC has been set up
*/ */
private readonly _ready = signal(false); private readonly _ready = signal(false);
public readonly ready = this._ready.asReadonly(); public readonly ready = this._ready.asReadonly();
@ -62,12 +64,15 @@ export class OidcService {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return; // Don't update tokens when we're not logged in. But what's going on? if (!user) return; // Don't update tokens when we're not logged in. But what's going on?
// TODO: Do we need to refresh the SignalR connection here?
user.oidcToken = this.token; user.oidcToken = this.token;
this.messageHub.stopHubConnection();
this.messageHub.createHubConnection(user);
}); });
}); });
this.config().subscribe(oidcSetting => { this.getPublicOidcConfig().subscribe(oidcSetting => {
this._settings.set(oidcSetting);
if (!oidcSetting.authority) { if (!oidcSetting.authority) {
this._loaded.set(true); this._loaded.set(true);
return return
@ -86,7 +91,6 @@ export class OidcService {
// Not all OIDC providers follow this nicely // Not all OIDC providers follow this nicely
strictDiscoveryDocumentValidation: false, strictDiscoveryDocumentValidation: false,
}); });
this._settings.set(oidcSetting);
this.oauth2.setupAutomaticSilentRefresh(); this.oauth2.setupAutomaticSilentRefresh();
from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({
@ -113,7 +117,7 @@ export class OidcService {
} }
} }
config() { getPublicOidcConfig() {
return this.httpClient.get<OidcPublicConfig>(this.apiBaseUrl + "oidc/config"); return this.httpClient.get<OidcPublicConfig>(this.apiBaseUrl + "oidc/config");
} }

View file

@ -1,20 +1,5 @@
import {AgeRating} from "../../_models/metadata/age-rating"; import {AgeRating} from "../../_models/metadata/age-rating";
export interface OidcConfig {
authority: string;
clientId: string;
provisionAccounts: boolean;
requireVerifiedEmail: boolean;
syncUserSettings: boolean;
autoLogin: boolean;
disablePasswordAuthentication: boolean;
providerName: string;
defaultRoles: string[];
defaultLibraries: number[];
defaultAgeRating: AgeRating;
defaultIncludeUnknowns: boolean;
}
export interface OidcPublicConfig { export interface OidcPublicConfig {
authority: string; authority: string;
clientId: string; clientId: string;
@ -22,3 +7,14 @@ export interface OidcPublicConfig {
disablePasswordAuthentication: boolean; disablePasswordAuthentication: boolean;
providerName: string; providerName: string;
} }
export interface OidcConfig extends OidcPublicConfig {
provisionAccounts: boolean;
requireVerifiedEmail: boolean;
syncUserSettings: boolean;
defaultRoles: string[];
defaultLibraries: number[];
defaultAgeRating: AgeRating;
defaultIncludeUnknowns: boolean;
}

View file

@ -3,10 +3,9 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed, computed,
DestroyRef, effect, DestroyRef,
inject, inject,
input, model,
Input, model,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
@ -27,8 +26,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ServerSettings} from "../_models/server-settings"; import {ServerSettings} from "../_models/server-settings";
import {UserOwner, UserOwners} from "../../_models/user"; import {UserOwner, UserOwners} from "../../_models/user";
import {UserOwnerPipe} from "../../_pipes/user-owner.pipe"; import {UserOwnerPipe} from "../../_pipes/user-owner.pipe";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {OwnerIconComponent} from "../../shared/_components/owner-icon/owner-icon.component";
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/; const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -37,7 +34,7 @@ const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
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, UserOwnerPipe, SettingItemComponent, OwnerIconComponent], imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, UserOwnerPipe],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class EditUserComponent implements OnInit { export class EditUserComponent implements OnInit {
@ -80,8 +77,6 @@ export class EditUserComponent implements OnInit {
this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
this.userForm.addControl('owner', new FormControl(this.member().owner, [Validators.required])); this.userForm.addControl('owner', new FormControl(this.member().owner, [Validators.required]));
// TODO: Rework, bad hack
// Work around isLocked so we're able to downgrade users
this.userForm.get('owner')!.valueChanges.pipe( this.userForm.get('owner')!.valueChanges.pipe(
tap(value => { tap(value => {
const newOwner = parseInt(value, 10) as UserOwner; const newOwner = parseInt(value, 10) as UserOwner;

View file

@ -17,7 +17,7 @@
<ngx-extended-pdf-viewer <ngx-extended-pdf-viewer
#pdfViewer #pdfViewer
[src]="readerService.downloadPdf(this.chapterId)" [src]="readerService.downloadPdf(this.chapterId)"
[authorization]="'Bearer ' + user.token" [authorization]="'Bearer ' + user.oidcToken ?? user.token"
height="100vh" height="100vh"
[(page)]="currentPage" [(page)]="currentPage"
[textLayer]="true" [textLayer]="true"

View file

@ -80,7 +80,7 @@
"notice": "Warning!", "notice": "Warning!",
"out-of-sync": "This user was created via OIDC, if the SynUsers setting is turned on changes made may be lost", "out-of-sync": "This user was created via OIDC, if the SynUsers setting is turned on changes made may be lost",
"oidc-managed": "This user is managed via OIDC, contact your OIDC administrator if they require changes.", "oidc-managed": "This user is managed via OIDC, contact your OIDC administrator if they require changes.",
"owner": "User type", "owner": "Ownership",
"owner-tooltip": "Native users will never be synced with OIDC" "owner-tooltip": "Native users will never be synced with OIDC"
}, },