POC oidc login

This commit is contained in:
Amelia 2025-05-24 13:57:06 +02:00
parent 6288d89651
commit df9d970a42
48 changed files with 5009 additions and 96 deletions

View file

@ -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" />

View file

@ -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>

View 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);
}
}

View 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; }
}

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>

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");
}
}
}

View file

@ -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");

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()
{

View file

@ -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();
}

View file

@ -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

View file

@ -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,
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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;
}
}

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 value;
if (!value.StartsWith(prefix)) return value;
return value.Substring(prefix.Length);
}
}

View file

@ -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:

View 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; } = [];
}
}

View file

@ -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
View 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);
}
}
}

View file

@ -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 &&