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 &&
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue