Make a proper disction betwen who owns the account, preperation for actual sync
This commit is contained in:
parent
dc91696769
commit
9fb29dec20
25 changed files with 4021 additions and 57 deletions
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
3728
API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs
generated
Normal file
3728
API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
39
API/Data/Migrations/20250701154425_AppUserOwner.cs
Normal file
39
API/Data/Migrations/20250701154425_AppUserOwner.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
16
API/Entities/Enums/AppUserOwner.cs
Normal file
16
API/Entities/Enums/AppUserOwner.cs
Normal 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,
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue