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

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