Cleanup and some edge case fixes
This commit is contained in:
parent
b6bfc65bc4
commit
6e72c74fde
17 changed files with 172 additions and 148 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue