POC oidc login
This commit is contained in:
parent
6288d89651
commit
df9d970a42
48 changed files with 5009 additions and 96 deletions
|
@ -71,7 +71,7 @@
|
|||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||
|
|
|
@ -74,6 +74,18 @@ public class AccountController : BaseApiController
|
|||
_localizationService = localizationService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UserDto>> GetCurrentUserAsync()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
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>
|
||||
/// Update a user's password
|
||||
/// </summary>
|
||||
|
@ -245,6 +257,11 @@ public class AccountController : BaseApiController
|
|||
}
|
||||
}
|
||||
|
||||
return Ok(await ConstructUserDto(user));
|
||||
}
|
||||
|
||||
private async Task<UserDto> ConstructUserDto(AppUser user)
|
||||
{
|
||||
// Update LastActive on account
|
||||
user.UpdateLastActive();
|
||||
|
||||
|
@ -265,12 +282,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>
|
||||
|
|
23
API/Controllers/OidcControlller.cs
Normal file
23
API/Controllers/OidcControlller.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Settings;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
[AllowAnonymous]
|
||||
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork): BaseApiController
|
||||
{
|
||||
|
||||
// TODO: Decide what we want to expose here, not really anything useful in it. But the discussion is needed
|
||||
// Public endpoint
|
||||
[HttpGet("config")]
|
||||
public async Task<ActionResult<OidcConfigDto>> GetOidcConfig()
|
||||
{
|
||||
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
return Ok(settings.OidcConfig);
|
||||
}
|
||||
|
||||
}
|
32
API/DTOs/Settings/OidcConfigDto.cs
Normal file
32
API/DTOs/Settings/OidcConfigDto.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
#nullable enable
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
public class OidcConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Base url for authority, must have /.well-known/openid-configuration
|
||||
/// </summary>
|
||||
public string? Authority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ClientId configured in your OpenID Connect provider
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
/// <summary>
|
||||
/// Create a new account when someone logs in with an unmatched account, if <see cref="RequireVerifiedEmail"/> is true,
|
||||
/// will account will be verified by default
|
||||
/// </summary>
|
||||
public bool ProvisionAccounts { get; set; }
|
||||
/// <summary>
|
||||
/// Require emails from OpenIDConnect to be verified before use
|
||||
/// </summary>
|
||||
public bool RequireVerifiedEmail { get; set; }
|
||||
/// <summary>
|
||||
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
|
||||
/// </summary>
|
||||
public bool ProvisionUserSettings { get; set; }
|
||||
/// <summary>
|
||||
/// Try logging in automatically when opening the app
|
||||
/// </summary>
|
||||
public bool AutoLogin { get; set; }
|
||||
}
|
|
@ -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>
|
||||
|
|
3574
API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs
generated
Normal file
3574
API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
28
API/Data/Migrations/20250520073818_OpenIDConnect.cs
Normal file
28
API/Data/Migrations/20250520073818_OpenIDConnect.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -85,6 +85,9 @@ namespace API.Data.Migrations
|
|||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasRunScrobbleEventGeneration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -252,6 +252,12 @@ 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.OidcAutoLogin, Value = "false"},
|
||||
new() { Key = ServerSettingKey.OidcProvisionAccounts, Value = "false"},
|
||||
new() { Key = ServerSettingKey.OidcRequireVerifiedEmail, Value = "true"},
|
||||
new() { Key = ServerSettingKey.OidcProvisionUserSettings, Value = "false"},
|
||||
|
||||
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
|
||||
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
|
||||
|
@ -288,6 +294,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();
|
||||
}
|
||||
|
|
|
@ -88,6 +88,11 @@ 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>
|
||||
/// A list of Series the user doesn't want scrobbling for
|
||||
|
|
|
@ -197,4 +197,34 @@ 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>
|
||||
/// Optional OpenID Connect ClientSecret, required if authority is set
|
||||
/// </summary>
|
||||
[Description("OpenIdConnectAutoLogin")]
|
||||
OidcAutoLogin = 42,
|
||||
/// <summary>
|
||||
/// If true, auto creates a new account when someone logs in via OpenID Connect
|
||||
/// </summary>
|
||||
[Description("OpenIDConnectCreateAccounts")]
|
||||
OidcProvisionAccounts = 43,
|
||||
/// <summary>
|
||||
/// Require emails to be verified by the OpenID Connect provider when creating accounts on login
|
||||
/// </summary>
|
||||
[Description("OpenIDConnectVerifiedEmail")]
|
||||
OidcRequireVerifiedEmail = 44,
|
||||
/// <summary>
|
||||
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
|
||||
/// </summary>
|
||||
[Description("OpenIDConnectSyncUserSettings")]
|
||||
OidcProvisionUserSettings = 45,
|
||||
}
|
||||
|
|
|
@ -80,6 +80,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);
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ namespace API.Extensions;
|
|||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
private const string NotAuthenticatedMessage = "User is not authenticated";
|
||||
private static readonly string EmailVerifiedClaimType = "email_verified";
|
||||
|
||||
/// <summary>
|
||||
/// Get's the authenticated user's username
|
||||
/// </summary>
|
||||
|
@ -26,4 +28,17 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,34 @@
|
|||
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.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.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 +61,139 @@ 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;
|
||||
};
|
||||
});
|
||||
|
||||
if (Configuration.OidcEnabled)
|
||||
{
|
||||
services.AddScoped<IClaimsTransformation, RolesClaimsTransformation>();
|
||||
|
||||
// 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.AddAuthorization(opt =>
|
||||
{
|
||||
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));
|
||||
opt.AddPolicy("RequireDownloadRole",
|
||||
policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
|
||||
opt.AddPolicy("RequireChangePasswordRole",
|
||||
policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
|
||||
{
|
||||
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
|
||||
if (ctx.Principal == null) return;
|
||||
|
||||
var user = await oidcService.LoginOrCreate(ctx.Principal);
|
||||
if (user == null) return;
|
||||
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
|
||||
new(ClaimTypes.Name, user.UserName ?? string.Empty)
|
||||
};
|
||||
|
||||
var userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 value;
|
||||
|
||||
if (!value.StartsWith(prefix)) return value;
|
||||
|
||||
return value.Substring(prefix.Length);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -129,6 +129,30 @@ 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.OidcAutoLogin:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
destination.OidcConfig.AutoLogin = bool.Parse(row.Value);
|
||||
break;
|
||||
case ServerSettingKey.OidcProvisionAccounts:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
destination.OidcConfig.ProvisionAccounts = bool.Parse(row.Value);
|
||||
break;
|
||||
case ServerSettingKey.OidcRequireVerifiedEmail:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
destination.OidcConfig.RequireVerifiedEmail = bool.Parse(row.Value);
|
||||
break;
|
||||
case ServerSettingKey.OidcProvisionUserSettings:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
destination.OidcConfig.ProvisionUserSettings = bool.Parse(row.Value);
|
||||
break;
|
||||
case ServerSettingKey.LicenseKey:
|
||||
case ServerSettingKey.EnableAuthentication:
|
||||
case ServerSettingKey.EmailServiceUrl:
|
||||
|
|
47
API/Helpers/RolesClaimsTransformation.cs
Normal file
47
API/Helpers/RolesClaimsTransformation.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace API.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Adds assigned roles from Keycloak under the default <see cref="ClaimTypes.Role"/> claim
|
||||
/// </summary>
|
||||
public class RolesClaimsTransformation: IClaimsTransformation
|
||||
{
|
||||
private const string ResourceAccessClaim = "resource_access";
|
||||
private string _clientId;
|
||||
|
||||
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
var resourceAccess = principal.FindFirst(ResourceAccessClaim);
|
||||
if (resourceAccess == null) return Task.FromResult(principal);
|
||||
|
||||
var resources = JsonSerializer.Deserialize<Dictionary<string, Resource>>(resourceAccess.Value);
|
||||
if (resources == null) return Task.FromResult(principal);
|
||||
|
||||
if (string.IsNullOrEmpty(_clientId))
|
||||
{
|
||||
_clientId = Configuration.OidcClientId;
|
||||
}
|
||||
|
||||
var kavitaResource = resources.GetValueOrDefault(_clientId);
|
||||
if (kavitaResource == null) return Task.FromResult(principal);
|
||||
|
||||
foreach (var role in kavitaResource.Roles)
|
||||
{
|
||||
((ClaimsIdentity)principal.Identity)?.AddClaim(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
return Task.FromResult(principal);
|
||||
}
|
||||
|
||||
private sealed class Resource
|
||||
{
|
||||
[JsonPropertyName("roles")]
|
||||
public IList<string> Roles { get; set; } = [];
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ 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.Errors;
|
||||
|
@ -29,6 +30,9 @@ public interface IAccountService
|
|||
Task<bool> HasBookmarkPermission(AppUser? user);
|
||||
Task<bool> HasDownloadPermission(AppUser? user);
|
||||
Task<bool> CanChangeAgeRestriction(AppUser? user);
|
||||
|
||||
Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole);
|
||||
Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles);
|
||||
}
|
||||
|
||||
public class AccountService : IAccountService
|
||||
|
@ -143,4 +147,56 @@ 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()).ToList();
|
||||
List<Library> libraries;
|
||||
if (hasAdminRole)
|
||||
{
|
||||
_logger.LogInformation("{UserName} is being registered as 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 = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(librariesIds, LibraryIncludes.AppUser)).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 [];
|
||||
}
|
||||
}
|
||||
|
|
209
API/Services/OidcService.cs
Normal file
209
API/Services/OidcService.cs
Normal file
|
@ -0,0 +1,209 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
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 AutoMapper;
|
||||
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);
|
||||
}
|
||||
|
||||
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
|
||||
IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService
|
||||
{
|
||||
private const string LibraryAccessClaim = "library";
|
||||
private const string AgeRatingClaim = "AgeRating";
|
||||
|
||||
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("oidc.errors.missing-external-id");
|
||||
|
||||
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences);
|
||||
if (user != null)
|
||||
{
|
||||
// await ProvisionUserSettings(settings, principal, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (string.IsNullOrEmpty(email))
|
||||
throw new KavitaException("oidc.errors.missing-email");
|
||||
|
||||
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
|
||||
throw new KavitaException("oidc.errors.email-not-verified");
|
||||
|
||||
|
||||
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences)
|
||||
?? await NewUserFromOpenIdConnect(settings, principal);
|
||||
if (user == null) return null;
|
||||
|
||||
user.ExternalId = externalId;
|
||||
|
||||
// await ProvisionUserSettings(settings, principal, user);
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole))
|
||||
throw new KavitaException("oidc.errors.disabled-account");
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
if (!settings.ProvisionAccounts) return null;
|
||||
|
||||
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
|
||||
if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null;
|
||||
|
||||
var name = claimsPrincipal.FindFirstValue(ClaimTypes.Name);
|
||||
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName);
|
||||
name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname);
|
||||
name ??= emailClaim.Value;
|
||||
|
||||
var other = await unitOfWork.UserRepository.GetUserByUsernameAsync(name);
|
||||
if (other != null)
|
||||
{
|
||||
// We match by email, so this will always be unique
|
||||
name = emailClaim.Value;
|
||||
}
|
||||
|
||||
// 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).ToString());
|
||||
throw new KavitaException("oidc.errors.creating-user");
|
||||
}
|
||||
|
||||
AddDefaultStreamsToUser(user, mapper);
|
||||
|
||||
if (settings.RequireVerifiedEmail)
|
||||
{
|
||||
// Email has been verified by OpenID Connect provider
|
||||
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
await userManager.ConfirmEmailAsync(user, token);
|
||||
}
|
||||
|
||||
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates roles, library access and age rating. Does not assign admin role, or to admin roles
|
||||
/// </summary>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="claimsPrincipal"></param>
|
||||
/// <param name="user"></param>
|
||||
/// <remarks>Extra feature, little buggy for now</remarks>
|
||||
private async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
if (!settings.ProvisionUserSettings) return;
|
||||
|
||||
var userRoles = await userManager.GetRolesAsync(user);
|
||||
if (userRoles.Contains(PolicyConstants.AdminRole)) return;
|
||||
|
||||
await SyncRoles(claimsPrincipal, user);
|
||||
await SyncLibraries(claimsPrincipal, user);
|
||||
SyncAgeRating(claimsPrincipal, user);
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var roles = claimsPrincipal.FindAll(ClaimTypes.Role)
|
||||
.Select(r => r.Value)
|
||||
.Where(r => PolicyConstants.ValidRoles.Contains(r))
|
||||
.Where(r => r != PolicyConstants.AdminRole)
|
||||
.ToList();
|
||||
if (roles.Count == 0) return;
|
||||
|
||||
var errors = await accountService.UpdateRolesForUser(user, roles);
|
||||
if (errors.Any()) throw new KavitaException("oidc.errors.syncing-user");
|
||||
}
|
||||
|
||||
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var libraryAccess = claimsPrincipal
|
||||
.FindAll(LibraryAccessClaim)
|
||||
.Select(r => r.Value)
|
||||
.ToList();
|
||||
if (libraryAccess.Count == 0) return;
|
||||
|
||||
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 static void SyncAgeRating(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
|
||||
var ageRatings = claimsPrincipal
|
||||
.FindAll(AgeRatingClaim)
|
||||
.Select(r => r.Value)
|
||||
.ToList();
|
||||
if (ageRatings.Count == 0) return;
|
||||
|
||||
var highestAgeRating = AgeRating.NotApplicable;
|
||||
|
||||
foreach (var ar in ageRatings)
|
||||
{
|
||||
if (!Enum.TryParse(ar, out AgeRating ageRating)) continue;
|
||||
|
||||
if (ageRating > highestAgeRating)
|
||||
{
|
||||
highestAgeRating = ageRating;
|
||||
}
|
||||
}
|
||||
|
||||
user.AgeRestriction = highestAgeRating;
|
||||
}
|
||||
|
||||
// DUPLICATED CODE
|
||||
private static void AddDefaultStreamsToUser(AppUser user, IMapper mapper)
|
||||
{
|
||||
foreach (var newStream in Seed.DefaultStreams.Select(mapper.Map<AppUserDashboardStream, AppUserDashboardStream>))
|
||||
{
|
||||
user.DashboardStreams.Add(newStream);
|
||||
}
|
||||
|
||||
foreach (var stream in Seed.DefaultSideNavStreams.Select(mapper.Map<AppUserSideNavStream, AppUserSideNavStream>))
|
||||
{
|
||||
user.SideNavStreams.Add(stream);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ 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;
|
||||
|
@ -172,7 +173,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 +347,26 @@ public class SettingsService : ISettingsService
|
|||
return updateSettingsDto;
|
||||
}
|
||||
|
||||
private async Task<bool> IsValidAuthority(string authority)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authority))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var url = authority + "/.well-known/openid-configuration";
|
||||
try
|
||||
{
|
||||
var resp = await url.GetAsync();
|
||||
return resp.StatusCode == 200;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "OpenIdConfiguration failed: {Reason}", e.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
|
||||
{
|
||||
_directoryService.ExistOrCreate(bookmarkDirectory);
|
||||
|
@ -379,6 +400,52 @@ public class SettingsService : ISettingsService
|
|||
return false;
|
||||
}
|
||||
|
||||
private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.OidcAuthority &&
|
||||
updateSettingsDto.OidcConfig.Authority + string.Empty != setting.Value)
|
||||
{
|
||||
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
|
||||
{
|
||||
throw new KavitaException("oidc-invalid-authority");
|
||||
}
|
||||
|
||||
setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty;
|
||||
Configuration.OidcAuthority = setting.Value;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.OidcClientId &&
|
||||
updateSettingsDto.OidcConfig.ClientId + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.OidcConfig.ClientId + string.Empty;
|
||||
Configuration.OidcClientId = setting.Value;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.OidcAutoLogin &&
|
||||
updateSettingsDto.OidcConfig.AutoLogin + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.OidcConfig.AutoLogin + string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.OidcProvisionAccounts &&
|
||||
updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.OidcConfig.ProvisionAccounts + string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.OidcProvisionUserSettings &&
|
||||
updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.OidcConfig.ProvisionUserSettings + string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.EmailHost &&
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
29
UI/Web/package-lock.json
generated
29
UI/Web/package-lock.json
generated
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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[];
|
||||
|
|
9
UI/Web/src/app/_routes/oidc-routing.module.ts
Normal file
9
UI/Web/src/app/_routes/oidc-routing.module.ts
Normal 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,
|
||||
}
|
||||
];
|
|
@ -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';
|
||||
|
@ -90,6 +90,10 @@ export class AccountService {
|
|||
});
|
||||
}
|
||||
|
||||
oidcEnabled() {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + "oidc/enabled");
|
||||
}
|
||||
|
||||
canInvokeAction(user: User, action: Action) {
|
||||
const isAdmin = this.hasAdminRole(user);
|
||||
const canDownload = this.hasDownloadRole(user);
|
||||
|
@ -167,6 +171,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;
|
||||
|
@ -202,7 +222,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -160,7 +160,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
|
||||
|
|
127
UI/Web/src/app/_services/oidc.service.ts
Normal file
127
UI/Web/src/app/_services/oidc.service.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
import {DestroyRef, Injectable} from '@angular/core';
|
||||
import {OAuthService} from "angular-oauth2-oidc";
|
||||
import {BehaviorSubject, from} from "rxjs";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {OidcConfig} from "../admin/_models/oidc-config";
|
||||
import {AccountService} from "./account.service";
|
||||
import {NavService} from "./nav.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {take} from "rxjs/operators";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OidcService {
|
||||
|
||||
/*
|
||||
TODO: Further cleanup, nicer handling for the user
|
||||
See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards
|
||||
Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts
|
||||
*/
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
settingsSource = new BehaviorSubject<OidcConfig | null>(null);
|
||||
settings$ = this.settingsSource.asObservable();
|
||||
|
||||
constructor(
|
||||
private oauth2: OAuthService,
|
||||
private httpClient: HttpClient,
|
||||
private accountService: AccountService,
|
||||
private navService: NavService,
|
||||
private router: Router,
|
||||
private destroyRef: DestroyRef,
|
||||
) {
|
||||
|
||||
this.config().subscribe(oidcSetting => {
|
||||
if (!oidcSetting.authority) {
|
||||
return
|
||||
}
|
||||
|
||||
this.oauth2.configure({
|
||||
issuer: oidcSetting.authority,
|
||||
clientId: oidcSetting.clientId,
|
||||
requireHttps: oidcSetting.authority.startsWith("https://"),
|
||||
redirectUri: window.location.origin + "/oidc/callback",
|
||||
postLogoutRedirectUri: window.location.origin + "/login",
|
||||
showDebugInformation: true,
|
||||
responseType: 'code',
|
||||
scope: "openid profile email roles offline_access",
|
||||
});
|
||||
this.settingsSource.next(oidcSetting);
|
||||
this.oauth2.setupAutomaticSilentRefresh();
|
||||
|
||||
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
|
||||
if (event.type !== "token_refreshed") return;
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (!user) return; // Don't update tokens when we're not logged in. But what's going on?
|
||||
|
||||
// TODO: Do we need to refresh the SignalR connection here?
|
||||
user.oidcToken = this.token;
|
||||
});
|
||||
});
|
||||
|
||||
from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({
|
||||
next: success => {
|
||||
if (!success) return;
|
||||
|
||||
this.tryLogin();
|
||||
},
|
||||
error: error => {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private tryLogin() {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) return;
|
||||
|
||||
if (this.token) {
|
||||
this.accountService.loginByToken(this.token).subscribe({
|
||||
next: _ => {
|
||||
this.doLogin();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
oidcLogin() {
|
||||
this.oauth2.initLoginFlow();
|
||||
}
|
||||
|
||||
config() {
|
||||
return this.httpClient.get<OidcConfig>(this.baseUrl + "oidc/config");
|
||||
}
|
||||
|
||||
get token() {
|
||||
return this.oauth2.getAccessToken();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.oauth2.logOut();
|
||||
}
|
||||
|
||||
private doLogin() {
|
||||
this.navService.showNavBar();
|
||||
this.navService.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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
9
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
9
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
|
||||
export interface OidcConfig {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
provisionAccounts: boolean;
|
||||
requireVerifiedEmail: boolean;
|
||||
provisionUserSettings: boolean;
|
||||
autoLogin: boolean;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
<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>
|
||||
<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">
|
||||
</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('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('provisionUserSettings'); as formControl) {
|
||||
<app-setting-switch [title]="t('provisionUserSettings')" [subtitle]="t('provisionUserSettings-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="provisionUserSettings" type="checkbox" class="form-check-input" formControlName="provisionUserSettings">
|
||||
</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>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</form>
|
||||
|
||||
</ng-container>
|
|
@ -0,0 +1,8 @@
|
|||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 5px;
|
||||
top: -42px;
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ServerSettings} from "../_models/server-settings";
|
||||
import {
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-open-idconnect',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SettingItemComponent,
|
||||
SettingSwitchComponent
|
||||
],
|
||||
templateUrl: './manage-open-idconnect.component.html',
|
||||
styleUrl: './manage-open-idconnect.component.scss'
|
||||
})
|
||||
export class ManageOpenIDConnectComponent implements OnInit {
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
oidcSettings!: OidcConfig;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
constructor(
|
||||
private settingsService: SettingsService,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getServerSettings().subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings = this.serverSettings.oidcConfig;
|
||||
|
||||
|
||||
// TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable
|
||||
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, []));
|
||||
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
|
||||
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
|
||||
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, []));
|
||||
this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, []));
|
||||
this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, []));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
save() {
|
||||
const data = this.settingsForm.getRawValue();
|
||||
const newSettings = Object.assign({}, this.serverSettings);
|
||||
newSettings.oidcConfig = data as OidcConfig;
|
||||
|
||||
this.settingsService.updateServerSettings(newSettings).subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings = data.oidcConfig;
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: error => {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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}}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -104,6 +104,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'},
|
||||
|
|
|
@ -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 {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); // Needed to auto login
|
||||
private readonly licenseService = inject(LicenseService);
|
||||
private readonly localizationService = inject(LocalizationService);
|
||||
|
||||
|
|
|
@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp
|
|||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||
import {WikiLink} from "../../../_models/wiki";
|
||||
import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component";
|
||||
import {OidcService} from "../../../_services/oidc.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-header',
|
||||
|
@ -64,6 +65,7 @@ export class NavHeaderComponent implements OnInit {
|
|||
private readonly searchService = inject(SearchService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
private readonly oidcService = inject(OidcService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly navService = inject(NavService);
|
||||
|
@ -135,6 +137,7 @@ export class NavHeaderComponent implements OnInit {
|
|||
this.accountService.logout();
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
this.oidcService.logout();
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<ng-container *transloco="let t; read: 'oidc'">
|
||||
<app-splash-container>
|
||||
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
||||
<ng-container body>
|
||||
|
||||
@if (error.length > 0) {
|
||||
<div class="invalid-feedback mb-2">
|
||||
{{t(error)}}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
|
||||
</ng-container>
|
||||
</app-splash-container>
|
||||
</ng-container>
|
|
@ -0,0 +1,3 @@
|
|||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import {ChangeDetectorRef, Component, OnInit} 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";
|
||||
import {OidcService} from "../../_services/oidc.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-oidc-callback',
|
||||
imports: [
|
||||
SplashContainerComponent,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './oidc-callback.component.html',
|
||||
styleUrl: './oidc-callback.component.scss'
|
||||
})
|
||||
export class OidcCallbackComponent implements OnInit{
|
||||
|
||||
error: string = '';
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private router: Router,
|
||||
private navService: NavService,
|
||||
private readonly cdRef: ChangeDetectorRef,
|
||||
private oidcService: OidcService,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goToLogin() {
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
|
@ -26,6 +26,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (oidcEnabled) {
|
||||
<button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.oidcLogin()">{{t('oidc')}}</button>
|
||||
}
|
||||
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</app-splash-container>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} 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 {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AccountService } from '../../_services/account.service';
|
||||
|
@ -10,6 +10,9 @@ import { NavService } from '../../_services/nav.service';
|
|||
import { NgIf } from '@angular/common';
|
||||
import { SplashContainerComponent } from '../_components/splash-container/splash-container.component';
|
||||
import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {OidcService} from "../../_services/oidc.service";
|
||||
import {forkJoin} from "rxjs";
|
||||
|
||||
|
||||
@Component({
|
||||
|
@ -17,10 +20,12 @@ 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, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip]
|
||||
})
|
||||
export class UserLoginComponent implements OnInit {
|
||||
|
||||
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}$")])
|
||||
|
@ -35,10 +40,18 @@ export class UserLoginComponent implements OnInit {
|
|||
*/
|
||||
isLoaded: boolean = false;
|
||||
isSubmitting = false;
|
||||
oidcEnabled = false;
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
|
||||
private toastr: ToastrService, private navService: NavService,
|
||||
private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private router: Router,
|
||||
private memberService: MemberService,
|
||||
private toastr: ToastrService,
|
||||
private navService: NavService,
|
||||
private readonly cdRef: ChangeDetectorRef,
|
||||
private route: ActivatedRoute,
|
||||
protected oidcService: OidcService,
|
||||
) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
|
@ -71,6 +84,18 @@ export class UserLoginComponent implements OnInit {
|
|||
if (val != null && val.length > 0) {
|
||||
this.login(val);
|
||||
}
|
||||
|
||||
const skipAutoLogin = params.get('skipAutoLogin') === 'true';
|
||||
this.oidcService.settings$.subscribe(cfg => {
|
||||
if (!cfg) return;
|
||||
|
||||
this.oidcEnabled = cfg.authority !== "";
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (cfg.autoLogin && !skipAutoLogin) {
|
||||
this.oidcService.oidcLogin()
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -83,18 +108,8 @@ export class UserLoginComponent implements OnInit {
|
|||
this.cdRef.markForCheck();
|
||||
this.accountService.login(model).subscribe(() => {
|
||||
this.loginForm.reset();
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.doLogin()
|
||||
|
||||
// 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 = false;
|
||||
this.cdRef.markForCheck();
|
||||
}, err => {
|
||||
|
@ -103,4 +118,19 @@ export class UserLoginComponent implements OnInit {
|
|||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private doLogin() {
|
||||
this.navService.showNavBar();
|
||||
this.navService.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -52,43 +52,45 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb
|
|||
import {
|
||||
ManageMetadataSettingsComponent
|
||||
} from "../../../admin/manage-metadata-settings/manage-metadata-settings.component";
|
||||
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [
|
||||
ChangeAgeRestrictionComponent,
|
||||
ChangeEmailComponent,
|
||||
ChangePasswordComponent,
|
||||
ManageDevicesComponent,
|
||||
ManageOpdsComponent,
|
||||
ManageScrobblingProvidersComponent,
|
||||
ManageUserPreferencesComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
ThemeManagerComponent,
|
||||
TranslocoDirective,
|
||||
UserStatsComponent,
|
||||
AsyncPipe,
|
||||
LicenseComponent,
|
||||
ManageEmailSettingsComponent,
|
||||
ManageLibraryComponent,
|
||||
ManageMediaSettingsComponent,
|
||||
ManageSettingsComponent,
|
||||
ManageSystemComponent,
|
||||
ManageTasksSettingsComponent,
|
||||
ManageUsersComponent,
|
||||
ServerStatsComponent,
|
||||
SettingFragmentPipe,
|
||||
ManageScrobblingComponent,
|
||||
ManageMediaIssuesComponent,
|
||||
ManageCustomizationComponent,
|
||||
ImportMalCollectionComponent,
|
||||
ImportCblComponent,
|
||||
ManageMatchedMetadataComponent,
|
||||
ManageUserTokensComponent,
|
||||
EmailHistoryComponent,
|
||||
ScrobblingHoldsComponent,
|
||||
ManageMetadataSettingsComponent
|
||||
],
|
||||
imports: [
|
||||
ChangeAgeRestrictionComponent,
|
||||
ChangeEmailComponent,
|
||||
ChangePasswordComponent,
|
||||
ManageDevicesComponent,
|
||||
ManageOpdsComponent,
|
||||
ManageScrobblingProvidersComponent,
|
||||
ManageUserPreferencesComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
ThemeManagerComponent,
|
||||
TranslocoDirective,
|
||||
UserStatsComponent,
|
||||
AsyncPipe,
|
||||
LicenseComponent,
|
||||
ManageEmailSettingsComponent,
|
||||
ManageLibraryComponent,
|
||||
ManageMediaSettingsComponent,
|
||||
ManageSettingsComponent,
|
||||
ManageSystemComponent,
|
||||
ManageTasksSettingsComponent,
|
||||
ManageUsersComponent,
|
||||
ServerStatsComponent,
|
||||
SettingFragmentPipe,
|
||||
ManageScrobblingComponent,
|
||||
ManageMediaIssuesComponent,
|
||||
ManageCustomizationComponent,
|
||||
ImportMalCollectionComponent,
|
||||
ImportCblComponent,
|
||||
ManageMatchedMetadataComponent,
|
||||
ManageUserTokensComponent,
|
||||
EmailHistoryComponent,
|
||||
ScrobblingHoldsComponent,
|
||||
ManageMetadataSettingsComponent,
|
||||
ManageOpenIDConnectComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -21,6 +21,7 @@ export enum SettingsTabId {
|
|||
|
||||
// Admin
|
||||
General = 'admin-general',
|
||||
OpenIDConnect = 'admin-oidc',
|
||||
Email = 'admin-email',
|
||||
Media = 'admin-media',
|
||||
Users = 'admin-users',
|
||||
|
@ -122,6 +123,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]),
|
||||
|
|
|
@ -5,7 +5,42 @@
|
|||
"password": "{{common.password}}",
|
||||
"password-validation": "{{validation.password-validation}}",
|
||||
"forgot-password": "Forgot Password?",
|
||||
"submit": "Sign in"
|
||||
"submit": "Sign in",
|
||||
"oidc": "Log in with OpenID Connect",
|
||||
"oidc-tooltip": "This will connect you to an external site"
|
||||
},
|
||||
|
||||
"oidc": {
|
||||
"title": "OpenID Connect Callback",
|
||||
"login": "Back to login screen",
|
||||
"errors": {
|
||||
"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"
|
||||
},
|
||||
"settings": {
|
||||
"save": "{{common.save}}",
|
||||
"notice": "Notice",
|
||||
"restart-required": "Changing OpenID Connect settings requires a restart",
|
||||
"provider": "Provider",
|
||||
"behaviour": "Behaviour",
|
||||
"field-required": "{{name}} is required when {{other}} is set",
|
||||
|
||||
"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",
|
||||
"provisionUserSettings": "Provision user settings",
|
||||
"provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information",
|
||||
"autoLogin": "Auto login",
|
||||
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen"
|
||||
}
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
|
@ -1698,6 +1733,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",
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue