Make a proper disction betwen who owns the account, preperation for actual sync

This commit is contained in:
Amelia 2025-07-01 17:46:39 +02:00
parent dc91696769
commit 9fb29dec20
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
25 changed files with 4021 additions and 57 deletions

View file

@ -532,6 +532,7 @@ public class AccountController : BaseApiController
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <remarks>OIDC managed users cannot be edited if SyncUsers is enabled</remarks>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
@ -544,6 +545,21 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
// Disallowed editing users synced via OIDC
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
if (user.Owner == AppUserOwner.OpenIdConnect &&
dto.Owner != AppUserOwner.Native &&
oidcSettings.SyncUserSettings)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed"));
}
var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser();
if (user.Id != defaultAdminUser.Id)
{
user.Owner = dto.Owner;
}
// Check if username is changing
if (!user.UserName!.Equals(dto.Username))
{

View file

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

View file

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

View file

@ -15,11 +15,7 @@ public record OidcConfigDto: OidcPublicConfigDto
/// <summary>
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
/// </summary>
public bool ProvisionUserSettings { get; set; }
/// <summary>
/// Requires roles to be configured in OIDC
/// </summary>
public bool RequireRoles { get; set; } = true;
public bool SyncUserSettings { get; set; }
/// <summary>
/// Returns true if the <see cref="OidcPublicConfigDto.Authority"/> has been set

View file

@ -1,6 +1,7 @@

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -800,6 +800,7 @@ public class UserRepository : IUserRepository
LastActiveUtc = u.LastActiveUtc,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
IsPending = !u.EmailConfirmed,
Owner = u.Owner,
AgeRestriction = new AgeRestrictionDto()
{
AgeRating = u.AgeRestriction,
@ -811,7 +812,7 @@ public class UserRepository : IUserRepository
Type = l.Type,
LastScanned = l.LastScanned,
Folders = l.Folders.Select(x => x.Path).ToList()
}).ToList()
}).ToList(),
})
.AsSplitQuery()
.AsNoTracking()

View file

@ -1,6 +1,8 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.DTOs.Settings;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Scrobble;
@ -93,6 +95,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// The sub returned the by OIDC provider
/// </summary>
public string? ExternalId { get; set; }
/// <summary>
/// Describes how the account was created
/// </summary>
public AppUserOwner Owner { get; set; } = AppUserOwner.Native;
/// <summary>

View file

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

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services;
using Kavita.Common;
@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext;
@ -158,9 +160,10 @@ public static class IdentityServiceExtensions
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
{
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
if (ctx.Principal == null) return;
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService<IUnitOfWork>();
var user = await oidcService.LoginOrCreate(ctx.Principal);
if (user == null)
{
@ -169,17 +172,25 @@ public static class IdentityServiceExtensions
return;
}
// Add the following claims like Kavita expects them
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
new(ClaimTypes.Name, user.UserName ?? string.Empty)
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 settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings)
{
var userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
var roles = await userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
}
else
{
claims.AddRange(ctx.Principal.Claims);
}
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
var principal = new ClaimsPrincipal(identity);

View file

@ -18,6 +18,7 @@
"generate-token": "There was an issue generating a confirmation email token. See logs",
"age-restriction-update": "There was an error updating the age restriction",
"no-user": "User does not exist",
"oidc-managed": "This user is managed by OIDC, cannot edit",
"username-taken": "Username already taken",
"email-taken": "Email already in use",
"user-already-confirmed": "User is already confirmed",

View file

@ -28,7 +28,7 @@ public interface IOidcService
/// <exception cref="KavitaException">if any requirements aren't met</exception>
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
/// <summary>
/// Updates roles, library access and age rating. Does not assign admin role, or to admin roles
/// Updates roles, library access and age rating. Will not modify the default admin
/// </summary>
/// <param name="settings"></param>
/// <param name="claimsPrincipal"></param>
@ -68,6 +68,9 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
throw new KavitaException("errors.oidc.email-not-verified");
if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0)
throw new KavitaException("errors.oidc.role-not-assigned");
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams)
?? await NewUserFromOpenIdConnect(settings, principal);
@ -126,6 +129,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
throw new KavitaException("errors.oidc.creating-user");
}
user.Owner = AppUserOwner.OpenIdConnect;
AddDefaultStreamsToUser(user, mapper);
await AddDefaultReadingProfileToUser(user);
@ -137,6 +141,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
}
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
await userManager.AddToRoleAsync(user, PolicyConstants.PlebRole);
await unitOfWork.CommitAsync();
return user;
@ -144,11 +149,10 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
public 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;
if (!settings.SyncUserSettings) return;
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
if (defaultAdminUser.Id == user.Id) return;
await SyncRoles(claimsPrincipal, user);
await SyncLibraries(claimsPrincipal, user);
@ -161,29 +165,24 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
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 roles = claimsPrincipal.GetAccessRoles();
var errors = await accountService.UpdateRolesForUser(user, roles);
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
}
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
var libraryAccess = claimsPrincipal
.FindAll(ClaimTypes.Role)
.Where(r => r.Value.StartsWith(LibraryAccessPrefix))
.Select(r => r.Value.TrimPrefix(LibraryAccessPrefix))
.ToList();
if (libraryAccess.Count == 0) return;
if (libraryAccess.Count == 0 && !hasAdminRole) 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);
}