Compare commits

...
Sign in to create a new pull request.

29 commits

Author SHA1 Message Date
Amelia
e5c716d234 Tweak refresh logic for OIDC 2025-07-09 12:42:07 +02:00
Amelia
061fb222e6 Include metadata setting in stats 2025-07-09 11:48:28 +02:00
Amelia
5e367418d1 Slight renaming 2025-07-09 11:48:27 +02:00
Amelia
7cdf69533b Actually update the owner 2025-07-06 16:50:56 +02:00
Amelia
6cac60342b Merge branch 'develop' into feature/oidc 2025-07-06 16:43:07 +02:00
Amelia
6e72c74fde Cleanup and some edge case fixes 2025-07-06 16:42:21 +02:00
Amelia
b6bfc65bc4 Share Stats about OIDC use 2025-07-06 14:39:05 +02:00
Amelia
d2e1ca9078 Some debug logging to help in case of issues 2025-07-04 18:19:57 +02:00
Amelia
4c397e0af0 Fix migration & OIDC not saving 2025-07-03 16:41:09 +02:00
Amelia
626bb3b719 Revert "Only use OIDC token when it's valid"
This reverts commit 63a5750f28.
2025-07-03 16:18:24 +02:00
Amelia
63a5750f28
Only use OIDC token when it's valid
Need to extend the wait logic to the JWT interceptor as well
2025-07-03 15:58:47 +02:00
Amelia
f868f5df91
Cleanup edit user modal 2025-07-01 23:32:57 +02:00
Amelia
9979220641
Bits of cleanup & support custom base urls 2025-07-01 21:41:19 +02:00
Amelia
08914f7546
Fix the login flashing sometimes
Race condition between being able to login via OIDC in the background,
and making first requests
2025-07-01 20:22:17 +02:00
Amelia
a122ae07a9
Add default values for when Sync is off 2025-07-01 20:02:37 +02:00
Amelia
9fb29dec20
Make a proper disction betwen who owns the account, preperation for actual sync 2025-07-01 17:46:39 +02:00
Amelia
dc91696769
Save all settings as json instead of having lots of keys 2025-07-01 13:50:59 +02:00
Amelia
e8f74709f3
Fix name check not working correctly 2025-07-01 13:17:38 +02:00
Amelia
5104a66cae
This can be done in Keycloak, no need to manually do it here, add missing translation 2025-07-01 08:25:32 +02:00
Amelia
7847ce4c1b
Ensure side nav streams aren't duplicated in sync 2025-06-30 21:51:13 +02:00
Amelia
4c0faa755d
Cleanup login page, custom button text 2025-06-30 20:27:53 +02:00
Amelia
54fb4c7a8a
Use roles to sync Libraries and Age Rating for simplicty 2025-06-30 19:08:49 +02:00
Amelia
9f94abe1be
Ensure default reading profile is created 2025-06-30 18:52:49 +02:00
Amelia
188020597c
Disable password auth setting 2025-06-30 15:54:36 +02:00
Amelia
1180d518a2
Authority url validator 2025-06-30 14:33:10 +02:00
Amelia
5480df4cfb
Disable strictDiscoveryDocumentValidation as some OIDC providers don't follow this 2025-06-29 22:27:08 +02:00
Amelia
0b64ea1622
Cleanup, nicer flow 2025-06-29 20:16:17 +02:00
Amelia
465723fedf
Merge branch 'develop' into feature/oidc 2025-06-29 18:20:13 +02:00
Amelia
df9d970a42 POC oidc login 2025-05-24 13:57:06 +02:00
79 changed files with 9497 additions and 181 deletions

View file

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

View file

@ -52,6 +52,7 @@ public class AccountController : BaseApiController
private readonly IEmailService _emailService;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly IOidcService _oidcService;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
@ -60,7 +61,8 @@ public class AccountController : BaseApiController
ILogger<AccountController> logger,
IMapper mapper, IAccountService accountService,
IEmailService emailService, IEventHub eventHub,
ILocalizationService localizationService)
ILocalizationService localizationService,
IOidcService oidcService)
{
_userManager = userManager;
_signInManager = signInManager;
@ -72,6 +74,32 @@ public class AccountController : BaseApiController
_emailService = emailService;
_eventHub = eventHub;
_localizationService = localizationService;
_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]
public async Task<ActionResult<UserDto>> GetCurrentUserAsync()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
if (user == null) throw new UnauthorizedAccessException();
if (user.Owner == AppUserOwner.OpenIdConnect)
{
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
await _oidcService.SyncUserSettings(oidcSettings, User, user);
}
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
return Ok(await ConstructUserDto(user));
}
/// <summary>
@ -151,10 +179,10 @@ public class AccountController : BaseApiController
if (!result.Succeeded) return BadRequest(result.Errors);
// Assign default streams
AddDefaultStreamsToUser(user);
_accountService.AddDefaultStreamsToUser(user);
// Assign default reading profile
await AddDefaultReadingProfileToUser(user);
await _accountService.AddDefaultReadingProfileToUser(user);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
@ -224,6 +252,11 @@ public class AccountController : BaseApiController
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
var oidcConfig = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
// Setting only takes effect if OIDC is functional, and if we're not logging in via ApiKey
var disablePasswordAuthentication = oidcConfig is {Enabled: true, DisablePasswordAuthentication: true} && string.IsNullOrEmpty(loginDto.ApiKey);
if (disablePasswordAuthentication && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "password-authentication-disabled"));
if (string.IsNullOrEmpty(loginDto.ApiKey))
{
var result = await _signInManager
@ -248,6 +281,11 @@ public class AccountController : BaseApiController
}
}
return Ok(await ConstructUserDto(user));
}
private async Task<UserDto> ConstructUserDto(AppUser user)
{
// Update LastActive on account
user.UpdateLastActive();
@ -268,12 +306,11 @@ public class AccountController : BaseApiController
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion))
.Value;
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName!);
if (pref == null) return Ok(dto);
if (pref == null) return dto;
pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
return Ok(dto);
return dto;
}
/// <summary>
@ -505,6 +542,7 @@ public class AccountController : BaseApiController
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <remarks>OIDC managed users cannot be edited if SyncUsers is enabled</remarks>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
@ -517,6 +555,28 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
// Disallowed editing users owned by OIDC
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
if (user.Owner == AppUserOwner.OpenIdConnect && dto.Owner != AppUserOwner.Native && oidcSettings.SyncUserSettings)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed"));
}
var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser();
if (user.Id == defaultAdminUser.Id && dto.Owner != AppUserOwner.Native)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "cannot-change-ownership-original-user"));
}
user.Owner = dto.Owner;
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
if (!user.UserName!.Equals(dto.Username))
{
@ -670,10 +730,10 @@ public class AccountController : BaseApiController
if (!result.Succeeded) return BadRequest(result.Errors);
// Assign default streams
AddDefaultStreamsToUser(user);
_accountService.AddDefaultStreamsToUser(user);
// Assign default reading profile
await AddDefaultReadingProfileToUser(user);
await _accountService.AddDefaultReadingProfileToUser(user);
// Assign Roles
var roles = dto.Roles;
@ -772,29 +832,6 @@ public class AccountController : BaseApiController
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>
/// Last step in authentication flow, confirms the email token for email
/// </summary>

View file

@ -0,0 +1,45 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Settings;
using API.Services;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork,
IMapper mapper, ISettingsService settingsService): BaseApiController
{
/// <summary>
/// Retrieve publicly required configuration regarding Oidc
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("config")]
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
{
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
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")]
[HttpPost("is-valid-authority")]
public async Task<ActionResult<bool>> IsValidAuthority([FromBody] IsValidAuthorityBody authority)
{
return Ok(await settingsService.IsValidAuthority(authority.Authority));
}
public class IsValidAuthorityBody
{
public string Authority { get; set; }
}
}

View file

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

View file

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

View file

@ -0,0 +1,38 @@
#nullable enable
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs.Settings;
public record OidcConfigDto: OidcPublicConfigDto
{
/// <summary>
/// If true, auto creates a new account when someone logs in via OpenID Connect
/// </summary>
public bool ProvisionAccounts { get; set; }
/// <summary>
/// Require emails to be verified by the OpenID Connect provider when creating accounts on login
/// </summary>
public bool RequireVerifiedEmail { get; set; } = true;
/// <summary>
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
/// </summary>
public bool SyncUserSettings { get; set; }
// Default values used when SyncUserSettings is false
#region Default user settings
public List<string> DefaultRoles { get; set; } = [];
public List<int> DefaultLibraries { get; set; } = [];
public AgeRating DefaultAgeRating { get; set; } = AgeRating.Unknown;
public bool DefaultIncludeUnknowns { get; set; } = false;
#endregion
/// <summary>
/// Returns true if the <see cref="OidcPublicConfigDto.Authority"/> has been set
/// </summary>
public bool Enabled => Authority != "";
}

View file

@ -0,0 +1,25 @@
#nullable enable
using API.Entities.Enums;
namespace API.DTOs.Settings;
public record OidcPublicConfigDto
{
/// <inheritdoc cref="ServerSettingKey.OidcAuthority"/>
public string? Authority { get; set; }
/// <inheritdoc cref="ServerSettingKey.OidcClientId"/>
public string? ClientId { get; set; }
/// <summary>
/// Automatically redirect to the Oidc login screen
/// </summary>
public bool AutoLogin { get; set; }
/// <summary>
/// Disables password authentication for non-admin users
/// </summary>
public bool DisablePasswordAuthentication { get; set; }
/// <summary>
/// Name of your provider, used to display on the login screen
/// </summary>
/// <remarks>Default to OpenID Connect</remarks>
public string ProviderName { get; set; } = "OpenID Connect";
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@

using System;
using API.DTOs.Account;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs;
#nullable enable
@ -15,4 +16,6 @@ public sealed record UserDto
public UserPreferencesDto? Preferences { get; set; }
public AgeRestrictionDto? AgeRestriction { get; init; }
public string KavitaVersion { get; set; }
/// <inheritdoc cref="AppUser.Owner"/>
public AppUserOwner Owner { get; init; }
}

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -87,6 +87,9 @@ namespace API.Data.Migrations
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("ExternalId")
.HasColumnType("TEXT");
b.Property<bool>("HasRunScrobbleEventGeneration")
.HasColumnType("INTEGER");
@ -116,6 +119,9 @@ namespace API.Data.Migrations
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Owner")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
@ -3637,7 +3643,8 @@ namespace API.Data.Migrations
b.Navigation("TableOfContents");
b.Navigation("UserPreferences");
b.Navigation("UserPreferences")
.IsRequired();
b.Navigation("UserRoles");

View file

@ -107,6 +107,7 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
Task<AppUser?> GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None);
}
public class UserRepository : IUserRepository
@ -557,6 +558,16 @@ public class UserRepository : IUserRepository
.FirstOrDefaultAsync();
}
public async Task<AppUser?> GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None)
{
if (string.IsNullOrEmpty(externalId)) return null;
return await _context.AppUser
.Where(u => u.ExternalId == externalId)
.Includes(includes)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
@ -789,6 +800,7 @@ public class UserRepository : IUserRepository
LastActiveUtc = u.LastActiveUtc,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
IsPending = !u.EmailConfirmed,
Owner = u.Owner,
AgeRestriction = new AgeRestrictionDto()
{
AgeRating = u.AgeRestriction,
@ -800,7 +812,7 @@ public class UserRepository : IUserRepository
Type = l.Type,
LastScanned = l.LastScanned,
Folders = l.Folders.Select(x => x.Path).ToList()
}).ToList()
}).ToList(),
})
.AsSplitQuery()
.AsNoTracking()

View file

@ -5,9 +5,11 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using API.Constants;
using API.Data.Repositories;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Theme;
@ -252,6 +254,9 @@ public static class Seed
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() { Key = ServerSettingKey.OidcAuthority, Value = Configuration.OidcAuthority},
new() { Key = ServerSettingKey.OidcClientId, Value = Configuration.OidcClientId},
new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())},
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
@ -288,6 +293,10 @@ public static class Seed
DirectoryService.BackupDirectory + string.Empty;
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value =
Configuration.CacheSize + string.Empty;
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcAuthority)).Value =
Configuration.OidcAuthority + string.Empty;
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcClientId)).Value =
Configuration.OidcClientId + string.Empty;
await context.SaveChangesAsync();
}

View file

@ -1,6 +1,8 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.DTOs.Settings;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Scrobble;
@ -89,6 +91,16 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// <remarks>Kavita+ only</remarks>
public DateTime ScrobbleEventGenerationRan { get; set; }
/// <summary>
/// The sub returned the by OIDC provider
/// </summary>
public string? ExternalId { get; set; }
/// <summary>
/// Describes who manages the account (may further depend on other settings)
/// </summary>
/// <remarks>Always fallbacks to native</remarks>
public AppUserOwner Owner { get; set; } = AppUserOwner.Native;
/// <summary>
/// A list of Series the user doesn't want scrobbling for

View file

@ -0,0 +1,16 @@
namespace API.Entities.Enums;
/// <summary>
/// Who own the user, can be updated in the UI if desired
/// </summary>
public enum AppUserOwner
{
/**
* Kavita has full control over the user
*/
Native = 0,
/**
* The user is synced with the OIDC provider
*/
OpenIdConnect = 1,
}

View file

@ -197,4 +197,20 @@ public enum ServerSettingKey
/// </summary>
[Description("FirstInstallVersion")]
FirstInstallVersion = 39,
/// <summary>
/// Optional OpenID Connect Authority URL
/// </summary>
[Description("OpenIDConnectAuthority")]
OidcAuthority = 40,
/// <summary>
/// Optional OpenID Connect ClientId, default to kavita
/// </summary>
[Description("OpenIDConnectClientId")]
OidcClientId = 41,
/// <summary>
/// A Json object of type <see cref="API.DTOs.Settings.OidcConfigDto"/>
/// </summary>
[Description("OidcConfiguration")]
OidcConfiguration = 42,
}

View file

@ -83,6 +83,8 @@ public static class ApplicationServiceExtensions
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
services.AddScoped<IWantToReadSyncService, WantToReadSyncService>();
services.AddScoped<IOidcService, OidcService>();
services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true);

View file

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

View file

@ -6,6 +6,7 @@ using API.Data.Misc;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using Microsoft.AspNetCore.Identity;
namespace API.Extensions;
#nullable enable
@ -68,4 +69,9 @@ public static class EnumerableExtensions
return q;
}
public static string AsJoinedString(this IEnumerable<IdentityError> errors)
{
return string.Join(",", errors.Select(e => e.Description));
}
}

View file

@ -1,20 +1,36 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext;
namespace API.Extensions;
#nullable enable
public static class IdentityServiceExtensions
{
private const string DynamicJwt = nameof(DynamicJwt);
private const string OpenIdConnect = nameof(OpenIdConnect);
private const string LocalIdentity = nameof(LocalIdentity);
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config)
{
services.Configure<IdentityOptions>(options =>
@ -47,42 +63,146 @@ public static class IdentityServiceExtensions
.AddRoleValidator<RoleValidator<AppRole>>()
.AddEntityFrameworkStores<DataContext>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
var auth = services.AddAuthentication(DynamicJwt)
.AddPolicyScheme(DynamicJwt, JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters()
var iss = Configuration.OidcAuthority;
var enabled = Configuration.OidcEnabled;
options.ForwardDefaultSelector = context =>
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita"
};
if (!enabled)
return LocalIdentity;
options.Events = new JwtBearerEvents()
{
OnMessageReceived = context =>
var fullAuth =
context.Request.Headers["Authorization"].FirstOrDefault() ??
context.Request.Query["access_token"].FirstOrDefault();
var token = fullAuth?.TrimPrefix("Bearer ");
if (string.IsNullOrEmpty(token))
return LocalIdentity;
var handler = new JwtSecurityTokenHandler();
try
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Only use query string based token on SignalR hubs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
var jwt = handler.ReadJwtToken(token);
if (jwt.Issuer == iss) return OpenIdConnect;
}
catch
{
/* Swallow */
}
return LocalIdentity;
};
});
services.AddAuthorization(opt =>
if (Configuration.OidcEnabled)
{
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole));
opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
// TODO: Investigate on how to make this not hardcoded at startup
auth.AddJwtBearer(OpenIdConnect, options =>
{
options.Authority = Configuration.OidcAuthority;
options.Audience = Configuration.OidcClientId;
options.RequireHttpsMetadata = options.Authority.StartsWith("https://");
options.TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = Configuration.OidcClientId,
ValidIssuer = Configuration.OidcAuthority,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
RequireExpirationTime = true,
ValidateLifetime = true,
RequireSignedTokens = true
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery,
OnTokenValidated = OidcClaimsPrincipalConverter,
};
});
}
auth.AddJwtBearer(LocalIdentity, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita"
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery,
};
});
services.AddAuthorizationBuilder()
.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole))
.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole))
.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
return services;
}
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
{
if (ctx.Principal == null) return;
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
var user = await oidcService.LoginOrCreate(ctx.Principal);
if (user == null)
{
ctx.Principal = null;
await ctx.HttpContext.SignOutAsync(OpenIdConnect);
return;
}
// Add the following claims like Kavita expects them
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 unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService<IUnitOfWork>();
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings)
{
var userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
var roles = await userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
}
else
{
claims.AddRange(ctx.Principal.Claims);
}
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
ctx.HttpContext.User = principal;
ctx.Principal = principal;
ctx.Success();
}
private static Task SetTokenFromQuery(MessageReceivedContext context)
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Only use query string based token on SignalR hubs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) context.Token = accessToken;
return Task.CompletedTask;
}
}

View file

@ -52,4 +52,13 @@ public static class StringExtensions
{
return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture);
}
public static string TrimPrefix(this string? value, string prefix)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (!value.StartsWith(prefix)) return value;
return value.Substring(prefix.Length);
}
}

View file

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

View file

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

View file

@ -2,6 +2,7 @@
"confirm-email": "You must confirm your email first",
"locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.",
"disabled-account": "Your account is disabled. Contact the server admin.",
"password-authentication-disabled": "Password authentication has been disabled, login via OpenID Connect",
"register-user": "Something went wrong when registering user",
"validate-email": "There was an issue validating your email: {0}",
"confirm-token-gen": "There was an issue generating a confirmation token",
@ -17,6 +18,8 @@
"generate-token": "There was an issue generating a confirmation email token. See logs",
"age-restriction-update": "There was an error updating the age restriction",
"no-user": "User does not exist",
"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",
"email-taken": "Email already in use",
"user-already-confirmed": "User is already confirmed",

View file

@ -2,18 +2,20 @@
using System.Collections.Generic;
using System.Linq;
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;
@ -29,6 +31,19 @@ public interface IAccountService
Task<bool> HasBookmarkPermission(AppUser? user);
Task<bool> HasDownloadPermission(AppUser? user);
Task<bool> CanChangeAgeRestriction(AppUser? user);
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <param name="librariesIds"></param>
/// <param name="hasAdminRole"></param>
/// <returns></returns>
/// <remarks>Ensure that the users SideNavStreams are loaded</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
@ -36,13 +51,16 @@ public class AccountService : IAccountService
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 AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork)
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork,
IMapper mapper)
{
_userManager = userManager;
_logger = logger;
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
@ -143,4 +161,79 @@ public class AccountService : IAccountService
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
}
public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole)
{
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList();
List<Library> libraries;
if (hasAdminRole)
{
_logger.LogInformation("{UserName} is admin. Granting access to all libraries",
user.UserName);
libraries = allLibraries;
}
else
{
// Remove user from all libraries
foreach (var lib in allLibraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Remove(user);
user.RemoveSideNavFromLibrary(lib);
}
libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList();
}
foreach (var lib in libraries)
{
lib.AppUsers ??= new List<AppUser>();
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();
}
}

271
API/Services/OidcService.cs Normal file
View file

@ -0,0 +1,271 @@
#nullable enable
using System;
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.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IOidcService
{
/// <summary>
/// Returns the user authenticated with OpenID Connect
/// </summary>
/// <param name="principal"></param>
/// <returns></returns>
/// <exception cref="KavitaException">if any requirements aren't met</exception>
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
/// <summary>
/// Updates roles, library access and age rating. Will not modify the default admin
/// </summary>
/// <param name="settings"></param>
/// <param name="claimsPrincipal"></param>
/// <param name="user"></param>
Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user);
/// <summary>
/// Remove <see cref="AppUser.ExternalId"/> from all users
/// </summary>
/// <returns></returns>
Task ClearOidcIds();
}
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
IUnitOfWork unitOfWork, IAccountService accountService): IOidcService
{
private const string LibraryAccessPrefix = "library-";
private const string AgeRatingPrefix = "age-rating-";
private const string IncludeUnknowns = AgeRatingPrefix + "include-unknowns";
public async Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal)
{
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(externalId))
throw new KavitaException("errors.oidc.missing-external-id");
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, 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)
{
logger.LogInformation("User {UserName} has matched on email to {ExternalId}", user.Id, externalId);
user.ExternalId = externalId;
await unitOfWork.CommitAsync();
return user;
}
// Cannot match on native account, try and create new one
if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0)
throw new KavitaException("errors.oidc.role-not-assigned");
user = await NewUserFromOpenIdConnect(settings, principal, externalId);
if (user == null) return null;
var roles = await userManager.GetRolesAsync(user);
if (roles.Count == 0 || !roles.Contains(PolicyConstants.LoginRole))
throw new KavitaException("errors.oidc.disabled-account");
return user;
}
public async Task ClearOidcIds()
{
var users = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in users)
{
user.ExternalId = null;
}
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)
{
if (!settings.ProvisionAccounts) return null;
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null;
var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value;
logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId);
// TODO: Move to account service, as we're sharing code with AccountController
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 (settings.RequireVerifiedEmail)
{
// Email has been verified by OpenID Connect provider
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
await userManager.ConfirmEmailAsync(user, token);
}
user.ExternalId = externalId;
user.Owner = AppUserOwner.OpenIdConnect;
accountService.AddDefaultStreamsToUser(user);
await accountService.AddDefaultReadingProfileToUser(user);
await SyncUserSettings(settings, claimsPrincipal, user);
await SetDefaults(settings, user);
await unitOfWork.CommitAsync();
return user;
}
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.DefaultAgeRating, 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.DefaultAgeRating;
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
await unitOfWork.CommitAsync();
}
public async Task SyncUserSettings(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.LogInformation("Syncing user {UserName} from OIDC", user.UserName);
await SyncRoles(claimsPrincipal, user);
await SyncLibraries(claimsPrincipal, user);
await SyncAgeRestriction(claimsPrincipal, user);
if (unitOfWork.HasChanges())
await unitOfWork.CommitAsync();
}
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var roles = claimsPrincipal.GetAccessRoles();
logger.LogDebug("Syncing access roles for user {UserName}, found roles {Roles}", user.UserName, roles);
var errors = await accountService.UpdateRolesForUser(user, roles);
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
}
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var libraryAccess = claimsPrincipal
.FindAll(ClaimTypes.Role)
.Where(r => r.Value.StartsWith(LibraryAccessPrefix))
.Select(r => r.Value.TrimPrefix(LibraryAccessPrefix))
.ToList();
logger.LogDebug("Syncing libraries for user {UserName}, found library roles {Roles}", user.UserName, libraryAccess);
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).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);
}
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
.FindAll(ClaimTypes.Role)
.Where(r => r.Value.StartsWith(AgeRatingPrefix))
.Select(r => r.Value.TrimPrefix(AgeRatingPrefix))
.ToList();
logger.LogDebug("Syncing age restriction for user {UserName}, found restrictions {Restrictions}", user.UserName, ageRatings);
var highestAgeRating = AgeRating.Unknown;
foreach (var ar in ageRatings)
{
if (!Enum.TryParse(ar, out AgeRating ageRating)) continue;
if (ageRating > highestAgeRating)
{
highestAgeRating = ageRating;
}
}
user.AgeRestriction = highestAgeRating;
user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns);
logger.LogDebug("Synced age restriction for user {UserName}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}",
user.UserName, ageRatings, user.AgeRestrictionIncludeUnknowns);
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.KavitaPlus.Metadata;
@ -10,12 +11,13 @@ using API.Entities.Enums;
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 Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace API.Services;
@ -23,6 +25,12 @@ public interface ISettingsService
{
Task<MetadataSettingsDto> UpdateMetadataSettings(MetadataSettingsDto dto);
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);
}
@ -33,16 +41,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>
@ -172,7 +182,7 @@ public class SettingsService : ISettingsService
updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
UpdateEmailSettings(setting, updateSettingsDto);
await UpdateOidcSettings(setting, updateSettingsDto);
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
@ -346,6 +356,27 @@ public class SettingsService : ISettingsService
return updateSettingsDto;
}
public async Task<bool> IsValidAuthority(string authority)
{
if (string.IsNullOrEmpty(authority))
{
return false;
}
var url = authority + "/.well-known/openid-configuration";
try
{
var json = await url.GetStringAsync();
var config = OpenIdConnectConfiguration.Create(json);
return config.Issuer == Configuration.OidcAuthority;
}
catch (Exception e)
{
_logger.LogTrace(e, "OpenIdConfiguration failed: {Reason}", e.Message);
return false;
}
}
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
{
_directoryService.ExistOrCreate(bookmarkDirectory);
@ -379,6 +410,41 @@ public class SettingsService : ISettingsService
return false;
}
private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key == ServerSettingKey.OidcAuthority && setting.Value != updateSettingsDto.OidcConfig.Authority)
{
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
{
throw new KavitaException("oidc-invalid-authority");
}
setting.Value = updateSettingsDto.OidcConfig.Authority;
Configuration.OidcAuthority = updateSettingsDto.OidcConfig.Authority;
_unitOfWork.SettingsRepository.Update(setting);
_logger.LogWarning("OIDC Authority is changing, clearing all external ids");
await _oidcService.ClearOidcIds();
return;
}
if (setting.Key == ServerSettingKey.OidcClientId && setting.Value != updateSettingsDto.OidcConfig.ClientId)
{
setting.Value = updateSettingsDto.OidcConfig.ClientId;
Configuration.OidcClientId = updateSettingsDto.OidcConfig.ClientId;
_unitOfWork.SettingsRepository.Update(setting);
return;
}
if (setting.Key != ServerSettingKey.OidcConfiguration) return;
var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig);
if (setting.Value == newValue) return;
setting.Value = newValue;
_unitOfWork.SettingsRepository.Update(setting);
}
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key == ServerSettingKey.EmailHost &&

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.Owner = user.Owner;
if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries))
{

View file

@ -14,6 +14,8 @@ public static class Configuration
public const int DefaultHttpPort = 5000;
public const int DefaultTimeOutSecs = 90;
public const long DefaultCacheMemory = 75;
public const string DefaultOidcAuthority = "";
public const string DefaultOidcClientId = "kavita";
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
@ -50,6 +52,20 @@ public static class Configuration
set => SetCacheSize(GetAppSettingFilename(), value);
}
public static string OidcAuthority
{
get => GetOidcAuthority(GetAppSettingFilename());
set => SetOidcAuthority(GetAppSettingFilename(), value);
}
public static string OidcClientId
{
get => GetOidcClientId(GetAppSettingFilename());
set => SetOidcClientId(GetAppSettingFilename(), value);
}
public static bool OidcEnabled => GetOidcAuthority(GetAppSettingFilename()) != "";
public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename());
private static string GetAppSettingFilename()
@ -312,6 +328,74 @@ public static class Configuration
}
#endregion
#region OIDC
private static string GetOidcAuthority(string filePath)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
return jsonObj.OidcAuthority;
}
catch (Exception ex)
{
Console.WriteLine("Error reading app settings: " + ex.Message);
}
return string.Empty;
}
private static void SetOidcAuthority(string filePath, string authority)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
jsonObj.OidcAuthority = authority;
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
catch (Exception)
{
/* Swallow exception */
}
}
private static string GetOidcClientId(string filePath)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
return jsonObj.OidcAudience;
}
catch (Exception ex)
{
Console.WriteLine("Error reading app settings: " + ex.Message);
}
return string.Empty;
}
private static void SetOidcClientId(string filePath, string audience)
{
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
jsonObj.OidcAudience = audience;
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
catch (Exception)
{
/* Swallow exception */
}
}
#endregion
private sealed class AppSettings
{
public string TokenKey { get; set; }
@ -326,6 +410,8 @@ public static class Configuration
public long Cache { get; set; } = DefaultCacheMemory;
// ReSharper disable once MemberHidesStaticFromOuterClass
public bool AllowIFraming { get; init; } = false;
public string OidcAuthority { get; set; } = DefaultOidcAuthority;
public string OidcAudience { get; set; } = DefaultOidcClientId;
#pragma warning restore S3218
}
}

View file

@ -33,6 +33,7 @@
"@siemens/ngx-datatable": "^22.4.1",
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
"@tweenjs/tween.js": "^25.0.0",
"angular-oauth2-oidc": "^19.0.0",
"bootstrap": "^5.3.2",
"charts.css": "^1.1.0",
"file-saver": "^2.0.5",
@ -541,7 +542,6 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz",
"integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==",
"dev": true,
"dependencies": {
"@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -569,7 +569,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@ -584,7 +583,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"engines": {
"node": ">= 14.16.0"
},
@ -4378,6 +4376,19 @@
}
}
},
"node_modules/angular-oauth2-oidc": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-19.0.0.tgz",
"integrity": "sha512-EogHyF7MpCJSjSKIyVmdB8pJu7dU5Ilj9VNVSnFbLng4F77PIlaE4egwKUlUvk0i4ZvmO9rLXNQCm05R7Tyhcw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.5.2"
},
"peerDependencies": {
"@angular/common": ">=19.0.0",
"@angular/core": ">=19.0.0"
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -4906,8 +4917,7 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
@ -5354,7 +5364,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -5364,7 +5373,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8181,8 +8189,7 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/replace-in-file": {
"version": "7.1.0",
@ -8403,7 +8410,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"devOptional": true
},
"node_modules/sass": {
"version": "1.85.0",
@ -8468,7 +8475,6 @@
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
@ -9093,7 +9099,6 @@
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -41,6 +41,7 @@
"@siemens/ngx-datatable": "^22.4.1",
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
"@tweenjs/tween.js": "^25.0.0",
"angular-oauth2-oidc": "^19.0.0",
"bootstrap": "^5.3.2",
"charts.css": "^1.1.0",
"file-saver": "^2.0.5",

View file

@ -16,7 +16,7 @@ export class JwtInterceptor implements HttpInterceptor {
if (user) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${user.token}`
Authorization: `Bearer ${user.oidcToken ?? user.token}`
}
});
}

View file

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

View file

@ -4,6 +4,9 @@ import {Preferences} from './preferences/preferences';
// This interface is only used for login and storing/retrieving JWT from local storage
export interface User {
username: string;
// 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
oidcToken?: string;
token: string;
refreshToken: string;
roles: string[];
@ -13,4 +16,12 @@ export interface User {
ageRestriction: AgeRestriction;
hasRunScrobbleEventGeneration: boolean;
scrobbleEventGenerationRan: string; // datetime
owner: UserOwner,
}
export enum UserOwner {
Native = 0,
OpenIdConnect = 1,
}
export const UserOwners: UserOwner[] = [UserOwner.Native, UserOwner.OpenIdConnect];

View file

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

View file

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {UserOwner} from "../_models/user";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'userOwnerPipe'
})
export class UserOwnerPipe implements PipeTransform {
transform(value: UserOwner, ...args: unknown[]): string {
switch (value) {
case UserOwner.Native:
return translate("creation-source-pipe.native");
case UserOwner.OpenIdConnect:
return translate("creation-source-pipe.oidc");
}
}
}

View file

@ -0,0 +1,26 @@
import {ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot} from '@angular/router';
import {inject, Injectable} from "@angular/core";
import {catchError, filter, Observable, of, take, timeout} from "rxjs";
import {OidcService} from "../_services/oidc.service";
import {ToastrService} from "ngx-toastr";
@Injectable({
providedIn: 'root'
})
export class OidcResolver implements Resolve<any> {
private oidcService = inject(OidcService);
private toastR = inject(ToastrService);
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> {
return this.oidcService.loaded$.pipe(
filter(value => value),
take(1),
timeout(5000),
catchError(err => {
console.log(err);
this.toastR.error("oidc.timeout");
return of(true);
}));
}
}

View file

@ -0,0 +1,9 @@
import {Routes} from "@angular/router";
import {OidcCallbackComponent} from "../registration/oidc-callback/oidc-callback.component";
export const routes: Routes = [
{
path: 'callback',
component: OidcCallbackComponent,
}
];

View file

@ -1,4 +1,4 @@
import {HttpClient} from '@angular/common/http';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {DestroyRef, inject, Injectable} from '@angular/core';
import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators';
@ -13,7 +13,7 @@ import {UserUpdateEvent} from '../_models/events/user-update-event';
import {AgeRating} from '../_models/metadata/age-rating';
import {AgeRestriction} from '../_models/metadata/age-restriction';
import {TextResonse} from '../_types/text-response';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {Action} from "./action-factory.service";
import {LicenseService} from "./license.service";
import {LocalizationService} from "./localization.service";
@ -63,6 +63,8 @@ export class AccountService {
return this.hasAdminRole(u);
}), shareReplay({bufferSize: 1, refCount: true}));
public currentUserSignal = toSignal(this.currentUserSource);
/**
@ -205,6 +207,22 @@ export class AccountService {
);
}
loginByToken(token: string) {
const headers = new HttpHeaders({
"Authorization": `Bearer ${token}`
})
return this.httpClient.get<User>(this.baseUrl + 'account', {headers}).pipe(
tap((response: User) => {
const user = response;
if (user) {
user.oidcToken = token;
this.setCurrentUser(user);
}
}),
takeUntilDestroyed(this.destroyRef)
);
}
setCurrentUser(user?: User, refreshConnections = true) {
const isSameUser = this.currentUser === user;
@ -240,7 +258,10 @@ export class AccountService {
this.messageHub.createHubConnection(this.currentUser);
this.licenseService.hasValidLicense().subscribe();
}
this.startRefreshTokenTimer();
// oidc handles refreshing itself
if (!this.currentUser.oidcToken) {
this.startRefreshTokenTimer();
}
}
}

View file

@ -165,7 +165,7 @@ export class MessageHubService {
createHubConnection(user: User) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'messages', {
accessTokenFactory: () => user.token
accessTokenFactory: () => user.oidcToken ?? user.token
})
.withAutomaticReconnect()
//.withStatefulReconnect() // Requires signalr@8.0

View file

@ -11,6 +11,7 @@ import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {WikiLink} from "../_models/wiki";
import {OidcService} from "./oidc.service";
/**
* NavItem used to construct the dropdown or NavLinkModal on mobile
@ -34,6 +35,7 @@ interface NavItem {
export class NavService {
private readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
@ -173,12 +175,28 @@ export class NavService {
}
logout() {
this.oidcService.logout();
this.accountService.logout();
this.hideNavBar();
this.hideSideNav();
this.router.navigateByUrl('/login');
}
handleLogin() {
this.showNavBar();
this.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
}
/**
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
*/

View file

@ -0,0 +1,128 @@
import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core';
import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc";
import {from} from "rxjs";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {OidcPublicConfig} from "../admin/_models/oidc-config";
import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop";
import {ToastrService} from "ngx-toastr";
import {translate} from "@jsverse/transloco";
import {APP_BASE_HREF} from "@angular/common";
/**
* Enum mirror of angular-oauth2-oidc events which are used in Kavita
*/
export enum OidcEvents {
/**
* Fired on token refresh, and when the first token is recieved
*/
TokenRefreshed = "token_refreshed"
}
@Injectable({
providedIn: 'root'
})
export class OidcService {
private readonly oauth2 = inject(OAuthService);
private readonly httpClient = inject(HttpClient);
private readonly destroyRef = inject(DestroyRef);
private readonly toastR = inject(ToastrService);
protected readonly baseUrl = inject(APP_BASE_HREF);
apiBaseUrl = environment.apiUrl;
public events$ = this.oauth2.events;
/**
* True when the OIDC discovery document has been loaded, and login tried. Or no OIDC has been set up
*/
private readonly _loaded = signal(false);
public readonly loaded = this._loaded.asReadonly();
public readonly loaded$ = toObservable(this.loaded);
public readonly inUse = computed(() => {
const loaded = this.loaded();
const settings = this.settings();
return loaded && settings && settings.authority.trim() !== '';
});
/**
* Public OIDC settings
*/
private readonly _settings = signal<OidcPublicConfig | undefined>(undefined);
public readonly settings = this._settings.asReadonly();
constructor() {
this.oauth2.setStorage(localStorage);
// log events in dev
if (!environment.production) {
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
if (event instanceof OAuthErrorEvent) {
console.error('OAuthErrorEvent:', event);
} else {
console.debug('OAuthEvent:', event);
}
});
}
this.getPublicOidcConfig().subscribe(oidcSetting => {
this._settings.set(oidcSetting);
if (!oidcSetting.authority) {
this._loaded.set(true);
return
}
this.oauth2.configure({
issuer: oidcSetting.authority,
clientId: oidcSetting.clientId,
// Require https in production unless localhost
requireHttps: environment.production ? 'remoteOnly' : false,
redirectUri: window.location.origin + this.baseUrl + "oidc/callback",
postLogoutRedirectUri: window.location.origin + this.baseUrl + "login",
showDebugInformation: !environment.production,
responseType: 'code',
scope: "openid profile email roles offline_access",
// Not all OIDC providers follow this nicely
strictDiscoveryDocumentValidation: false,
});
this.oauth2.setupAutomaticSilentRefresh();
from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({
next: _ => {
this._loaded.set(true);
if (!this.oauth2.hasValidAccessToken() && this.oauth2.getRefreshToken()) {
this.oauth2.refreshToken().catch(err => console.error("failed to refresh token on startup", err));
}
},
error: error => {
console.log(error);
this.toastR.error(translate("oidc.error-loading-info"))
}
});
})
}
login() {
this.oauth2.initLoginFlow();
}
logout() {
if (this.token) {
this.oauth2.logOut();
}
}
getPublicOidcConfig() {
return this.httpClient.get<OidcPublicConfig>(this.apiBaseUrl + "oidc/config");
}
get token() {
return this.oauth2.getAccessToken();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,195 @@
<ng-container *transloco="let t; prefix:'oidc.settings'">
<div class="position-relative">
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save()">{{t('save')}}</button>
</div>
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<h4>{{t('provider')}}</h4>
<div class="text-muted">{{t('manual-save')}}</div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('authority'); as formControl) {
<app-setting-item [title]="t('authority')" [subtitle]="t('authority-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-authority" class="form-control"
formControlName="authority" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-authority-validations" class="invalid-feedback">
@if (formControl.errors?.invalidUri) {
<div>{{t('invalidUri')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('clientId'); as formControl) {
<app-setting-item [title]="t('clientId')" [subtitle]="t('clientId-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-clientId" aria-describedby="oidc-clientId-validations" class="form-control"
formControlName="clientId" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-clientId-validations" class="invalid-feedback">
@if (formControl.errors?.requiredIf) {
<div>{{t('field-required', {name: 'clientId', other: formControl.errors?.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('behaviour')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('providerName'); as formControl) {
<app-setting-item [title]="t('providerName')" [subtitle]="t('providerName-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-providerName" aria-describedby="oidc-providerName-validations" class="form-control"
formControlName="providerName" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionAccounts'); as formControl) {
<app-setting-switch [title]="t('provisionAccounts')" [subtitle]="t('provisionAccounts-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provisionAccounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
<app-setting-switch [title]="t('requireVerifiedEmail')" [subtitle]="t('requireVerifiedEmail-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="requireVerifiedEmail" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('syncUserSettings'); as formControl) {
<app-setting-switch [title]="t('syncUserSettings')" [subtitle]="t('syncUserSettings-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="syncUserSettings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('autoLogin'); as formControl) {
<app-setting-switch [title]="t('autoLogin')" [subtitle]="t('autoLogin-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="autoLogin" type="checkbox" class="form-check-input" formControlName="autoLogin">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('disablePasswordAuthentication'); as formControl) {
<app-setting-switch [title]="t('disablePasswordAuthentication')" [subtitle]="t('disablePasswordAuthentication-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="disablePasswordAuthentication" type="checkbox" class="form-check-input" formControlName="disablePasswordAuthentication">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('defaults')}}</h4>
<div class="text-muted">{{t('defaults-requirement')}}</div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultAgeRating'); as formControl) {
<app-setting-item [title]="t('defaultAgeRating')" [subtitle]="t('defaultAgeRating-tooltip')">
<ng-template #view>
<div>{{formControl.value | ageRating}}</div>
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="defaultAgeRating">
<option value="-1">{{t('no-restriction')}}</option>
@for (ageRating of ageRatings(); track ageRating.value) {
<option [value]="ageRating.value">{{ageRating.title}}</option>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultIncludeUnknowns'); as formControl) {
<app-setting-switch [title]="t('defaultIncludeUnknowns')" [subtitle]="t('defaultIncludeUnknowns-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="defaultIncludeUnknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if (this.oidcSettings()) {
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoles($event)" [allowAdmin]="true" [preSelectedRoles]="selectedRoles()"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibraries($event)" [preselectedLibraries]="selectedLibraries()"></app-library-selector>
</div>
</div>
}
</ng-container>
</form>
</ng-container>

View file

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

View file

@ -0,0 +1,175 @@
import {ChangeDetectorRef, Component, DestroyRef, effect, OnInit, signal} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {ServerSettings} from "../_models/server-settings";
import {
AbstractControl,
AsyncValidatorFn,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn
} from "@angular/forms";
import {SettingsService} from "../settings.service";
import {OidcConfig} from "../_models/oidc-config";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {RestrictionSelectorComponent} from "../../user-settings/restriction-selector/restriction-selector.component";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {MetadataService} from "../../_services/metadata.service";
import {AgeRating} from "../../_models/metadata/age-rating";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {allRoles, Role} from "../../_services/account.service";
import {Library} from "../../_models/library/library";
import {LibraryService} from "../../_services/library.service";
import {LibrarySelectorComponent} from "../library-selector/library-selector.component";
import {RoleSelectorComponent} from "../role-selector/role-selector.component";
@Component({
selector: 'app-manage-open-idconnect',
imports: [
TranslocoDirective,
ReactiveFormsModule,
SettingItemComponent,
SettingSwitchComponent,
AgeRatingPipe,
LibrarySelectorComponent,
RoleSelectorComponent
],
templateUrl: './manage-open-idconnect.component.html',
styleUrl: './manage-open-idconnect.component.scss'
})
export class ManageOpenIDConnectComponent implements OnInit {
serverSettings!: ServerSettings;
oidcSettings = signal<OidcConfig | undefined>(undefined);
settingsForm: FormGroup = new FormGroup({});
ageRatings = signal<AgeRatingDto[]>([]);
selectedLibraries = signal<number[]>([]);
selectedRoles = signal<string[]>([]);
constructor(
private settingsService: SettingsService,
private cdRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private metadataService: MetadataService,
) {
}
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings.set(ratings);
});
this.settingsService.getServerSettings().subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings.set(this.serverSettings.oidcConfig);
this.selectedRoles.set(this.serverSettings.oidcConfig.defaultRoles);
this.selectedLibraries.set(this.serverSettings.oidcConfig.defaultLibraries);
this.settingsForm.addControl('authority', new FormControl(this.serverSettings.oidcConfig.authority, [], [this.authorityValidator()]));
this.settingsForm.addControl('clientId', new FormControl(this.serverSettings.oidcConfig.clientId, [this.requiredIf('authority')]));
this.settingsForm.addControl('provisionAccounts', new FormControl(this.serverSettings.oidcConfig.provisionAccounts, []));
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.serverSettings.oidcConfig.requireVerifiedEmail, []));
this.settingsForm.addControl('syncUserSettings', new FormControl(this.serverSettings.oidcConfig.syncUserSettings, []));
this.settingsForm.addControl('autoLogin', new FormControl(this.serverSettings.oidcConfig.autoLogin, []));
this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.serverSettings.oidcConfig.disablePasswordAuthentication, []));
this.settingsForm.addControl('providerName', new FormControl(this.serverSettings.oidcConfig.providerName, []));
this.settingsForm.addControl("defaultAgeRating", new FormControl(this.serverSettings.oidcConfig.defaultAgeRating, []));
this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, []));
this.cdRef.markForCheck();
this.settingsForm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
filter(() => {
// Do not auto save when provider settings have changed
const settings: OidcConfig = this.settingsForm.getRawValue();
return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId;
}),
tap(() => this.save())
).subscribe();
}
});
}
updateRoles(roles: string[]) {
this.selectedRoles.set(roles);
this.save();
}
updateLibraries(libraries: Library[]) {
this.selectedLibraries.set(libraries.map(l => l.id));
this.save();
}
save() {
if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings) return;
const data = this.settingsForm.getRawValue();
const newSettings = Object.assign({}, this.serverSettings);
newSettings.oidcConfig = data as OidcConfig;
newSettings.oidcConfig.defaultAgeRating = parseInt(newSettings.oidcConfig.defaultAgeRating as unknown as string, 10) as AgeRating;
newSettings.oidcConfig.defaultRoles = this.selectedRoles();
newSettings.oidcConfig.defaultLibraries = this.selectedLibraries();
this.settingsService.updateServerSettings(newSettings).subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings.set(data.oidcConfig);
this.cdRef.markForCheck();
},
error: error => {
console.error(error);
}
})
}
authorityValidator(): AsyncValidatorFn {
return (control: AbstractControl) => {
let uri: string = control.value;
if (!uri || uri.trim().length === 0) {
return of(null);
}
try {
new URL(uri);
} catch {
return of({'invalidUri': {'uri': uri}} as ValidationErrors)
}
if (uri.endsWith('/')) {
uri = uri.substring(0, uri.length - 1);
}
return this.settingsService.ifValidAuthority(uri).pipe(map(ok => {
if (ok) return null;
return {'invalidUri': {'uri': uri}} as ValidationErrors;
}));
}
}
requiredIf(other: string): ValidatorFn {
return (control): ValidationErrors | null => {
const otherControl = this.settingsForm.get(other);
if (!otherControl) return null;
if (otherControl.invalid) return null;
const v = otherControl.value;
if (!v || v.length === 0) return null;
const own = control.value;
if (own && own.length > 0) return null;
return {'requiredIf': {'other': other, 'otherValue': v}}
}
}
}

View file

@ -10,6 +10,7 @@
<table class="table table-striped">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">{{t('name-header')}}</th>
<th scope="col">{{t('last-active-header')}}</th>
<th scope="col">{{t('sharing-header')}}</th>
@ -20,6 +21,11 @@
<tbody>
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
<tr>
<td>
<div class="-flex flex-row justify-content-center align-items-center">
<app-owner-icon [owner]="member.owner" />
</div>
</td>
<td id="username--{{idx}}">
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
@if (member.isPending) {

View file

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

View file

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

View file

@ -78,6 +78,11 @@ export class SettingsService {
isValidCronExpression(val: string) {
if (val === '' || val === undefined || val === null) return of(false);
return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true'));
}
ifValidAuthority(authority: string) {
if (authority === '' || authority === undefined || authority === null) return of(false);
return this.http.post<boolean>(this.baseUrl + 'oidc/is-valid-authority', {authority});
}
}

View file

@ -2,12 +2,19 @@ import {NgModule} from '@angular/core';
import {PreloadAllModules, RouterModule, Routes} from '@angular/router';
import {AuthGuard} from './_guards/auth.guard';
import {LibraryAccessGuard} from './_guards/library-access.guard';
import {OidcResolver} from "./_resolvers/oidc.resolver";
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
runGuardsAndResolvers: 'always',
resolve: {
// Require OIDC discovery to be loaded before launching the app
// making sure we don't flash the login screen because we've made request before we could auto login
// If no OIDC is set up, this will resolve after one request to the backend
_: OidcResolver,
},
children: [
{
path: 'settings',
@ -103,6 +110,10 @@ const routes: Routes = [
path: 'login',
loadChildren: () => import('./_routes/registration.router.module').then(m => m.routes) // TODO: Refactor so we just use /registration/login going forward
},
{
path: 'oidc',
loadChildren: () => import('./_routes/oidc-routing.module').then(m => m.routes)
},
{path: 'libraries', pathMatch: 'full', redirectTo: 'home'},
{path: '**', pathMatch: 'prefix', redirectTo: 'home'},
{path: '**', pathMatch: 'full', redirectTo: 'home'},

View file

@ -1,7 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
DestroyRef, effect,
HostListener,
inject,
OnInit
@ -25,6 +25,7 @@ import {TranslocoService} from "@jsverse/transloco";
import {VersionService} from "./_services/version.service";
import {LicenseService} from "./_services/license.service";
import {LocalizationService} from "./_services/localization.service";
import {OidcEvents, OidcService} from "./_services/oidc.service";
@Component({
selector: 'app-root',
@ -51,6 +52,7 @@ export class AppComponent implements OnInit {
private readonly document = inject(DOCUMENT);
private readonly translocoService = inject(TranslocoService);
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
private readonly oidcService = inject(OidcService);
private readonly licenseService = inject(LicenseService);
private readonly localizationService = inject(LocalizationService);
@ -98,6 +100,25 @@ export class AppComponent implements OnInit {
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
// Update token, or login when one becomes available
this.oidcService.events$.subscribe(event => {
if (event.type !== OidcEvents.TokenRefreshed) return;
const user = this.accountService.currentUserSignal();
if (user) {
user.oidcToken = this.oidcService.token;
return;
}
this.accountService.loginByToken(this.oidcService.token).subscribe({
next: () => {
this.navService.handleLogin();
},
error: err => {
console.error(err);
}
});
});
}
@HostListener('window:resize', ['$event'])

View file

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

View file

@ -0,0 +1,10 @@
<ng-container *transloco="let t; prefix: 'oidc'">
@if (showSplash()) {
<app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body>
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
</ng-container>
</app-splash-container>
}
</ng-container>

View file

@ -0,0 +1,3 @@
.invalid-feedback {
display: inherit;
}

View file

@ -0,0 +1,49 @@
import {ChangeDetectorRef, Component, OnInit, signal} from '@angular/core';
import {SplashContainerComponent} from "../_components/splash-container/splash-container.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Router} from "@angular/router";
import {NavService} from "../../_services/nav.service";
import {take} from "rxjs/operators";
@Component({
selector: 'app-oidc-callback',
imports: [
SplashContainerComponent,
TranslocoDirective
],
templateUrl: './oidc-callback.component.html',
styleUrl: './oidc-callback.component.scss'
})
export class OidcCallbackComponent implements OnInit {
showSplash = signal(false);
constructor(
private accountService: AccountService,
private router: Router,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
}
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.navService.showNavBar();
this.navService.showSideNav();
this.router.navigateByUrl('/home');
this.cdRef.markForCheck();
}
});
// Show back to log in splash only after 1s, for a more seamless experience
setTimeout(() => this.showSplash.set(true), 1000);
}
goToLogin() {
this.router.navigateByUrl('/login');
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -55,6 +55,7 @@ import {
import {
ManageReadingProfilesComponent
} from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component";
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
@Component({
selector: 'app-settings',
@ -91,7 +92,8 @@ import {
EmailHistoryComponent,
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent,
ManageReadingProfilesComponent
ManageReadingProfilesComponent,
ManageOpenIDConnectComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',

View file

@ -0,0 +1,9 @@
@switch (owner()) {
@case (UserOwner.OpenIdConnect) {
<img [width]="size()" [height]="size()" ngSrc="assets/icons/open-id-connect-logo.svg" alt="open-id-connect-logo">
}
@case (UserOwner.Native) {
<!-- TODO: SVG for scaling -->
<img ngSrc="assets/icons/favicon-16x16.png" [width]="size()" [height]="size()" alt="kavita-logo">
}
}

View file

@ -0,0 +1,19 @@
import {Component, input} from '@angular/core';
import {NgOptimizedImage} from "@angular/common";
import {UserOwner} from "../../../_models/user";
@Component({
selector: 'app-owner-icon',
imports: [
NgOptimizedImage
],
templateUrl: './owner-icon.component.html',
styleUrl: './owner-icon.component.scss'
})
export class OwnerIconComponent {
owner = input.required<UserOwner>();
size = input<number>(16);
protected readonly UserOwner = UserOwner;
}

View file

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

View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -5,7 +5,55 @@
"password": "{{common.password}}",
"password-validation": "{{validation.password-validation}}",
"forgot-password": "Forgot Password?",
"submit": "Sign in"
"submit": "Sign in",
"oidc": "OpenID Connect"
},
"oidc": {
"title": "OpenID Connect Callback",
"login": "Back to login screen",
"error-loading-info": "An error occurred loading OpenID Connect info, contact your administrator",
"timeout": "OIDC resolution has timed out or an error has occurred",
"settings": {
"save": "{{common.save}}",
"notice": "Notice",
"restart-required": "Changing Authority, or Client ID requires a manual restart of Kavita to take effect.",
"provider": "Provider",
"behaviour": "Behaviour",
"field-required": "{{name}} is required when {{other}} is set",
"invalidUri": "The provider URL is not valid",
"manual-save": "Changing provider settings requires a manual save",
"authority": "Authority",
"authority-tooltip": "The URL to your OpenID Connect provider",
"clientId": "Client ID",
"clientId-tooltip": "The ClientID set in your OIDC provider, can be anything",
"provisionAccounts": "Provision accounts",
"provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account",
"requireVerifiedEmail": "Require verified emails",
"requireVerifiedEmail-tooltip": "Requires emails to be verified when creation an account or matching with existing ones. A newly created account with a verified email, will be auto verified on Kavita's side",
"syncUserSettings": "Sync user settings with OIDC roles",
"syncUserSettings-tooltip": "Users created from OIDC will be fully managed (Roles, Library Access, Age Rating) by the OIDC. If this is disabled, users will be unable to access any content after their account creation. Read the documentation for more information.",
"autoLogin": "Auto login",
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen",
"disablePasswordAuthentication": "Disable password authentication",
"disablePasswordAuthentication-tooltip": "Users with the admin role can bypass this restriction",
"providerName": "Provider name",
"providerName-tooltip": "Name show on the login screen",
"defaults": "Defaults",
"defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off",
"defaultIncludeUnknowns": "Include unknowns",
"defaultIncludeUnknowns-tooltip": "Include unknown age ratings",
"defaultAgeRating": "Age rating",
"defaultAgeRating-tooltip": "Maximum age rating shown to new users",
"no-restriction": "{{restriction-selector.no-restriction}}"
}
},
"creation-source-pipe": {
"native": "Native",
"oidc": "OpenID Connect"
},
"dashboard": {
@ -28,7 +76,12 @@
"cancel": "{{common.cancel}}",
"saving": "Saving…",
"update": "Update",
"account-detail-title": "Account Details"
"account-detail-title": "Account Details",
"notice": "Warning!",
"out-of-sync": "This user was created via OIDC, if the 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.",
"owner": "Ownership",
"owner-tooltip": "Native users will never be synced with OIDC"
},
"user-scrobble-history": {
@ -1698,6 +1751,7 @@
"import-section-title": "Import",
"kavitaplus-section-title": "Kavita+",
"admin-general": "General",
"admin-oidc": "OpenID Connect",
"admin-users": "Users",
"admin-libraries": "Libraries",
"admin-media": "Media",
@ -2421,7 +2475,16 @@
"invalid-password-reset-url": "Invalid reset password url",
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
"theme-manual-upload": "There was an issue creating Theme from manual upload",
"theme-already-in-use": "Theme already exists by that name"
"theme-already-in-use": "Theme already exists by that name",
"oidc": {
"missing-external-id": "OpenID Connect provider did not return a valid identifier",
"missing-email": "OpenID Connect provider did not return a valid email",
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
"no-account": "No matching account found",
"disabled-account": "This account is disabled, please contact an administrator",
"creating-user": "Failed to create a new user, please contact an administrator",
"role-not-assigned": "You do not have the required roles assigned to access this application"
}
},
"metadata-builder": {

View file

@ -20,6 +20,7 @@ import {distinctUntilChanged} from "rxjs/operators";
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
import {HttpLoader} from "./httpLoader";
import {provideOAuthClient} from "angular-oauth2-oidc";
const disableAnimations = !('animate' in document.documentElement);
@ -146,6 +147,7 @@ bootstrapApplication(AppComponent, {
useFactory: getBaseHref,
deps: [PlatformLocation]
},
provideOAuthClient(),
provideHttpClient(withInterceptorsFromDi())
]
} as ApplicationConfig)