using System; using System.Collections.Generic; 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.Services; using Kavita.Common; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext; using TokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext; namespace API.Extensions; #nullable enable public static class IdentityServiceExtensions { private const string DynamicHybrid = nameof(DynamicHybrid); public const string OpenIdConnect = nameof(OpenIdConnect); private const string LocalIdentity = nameof(LocalIdentity); private const string OidcCallback = "/signin-oidc"; private const string OidcLogoutCallback = "/signout-callback-oidc"; public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment environment) { services.Configure(options => { options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/"; }); services.AddIdentityCore(opt => { opt.Password.RequireNonAlphanumeric = false; opt.Password.RequireDigit = false; opt.Password.RequireDigit = false; opt.Password.RequireLowercase = false; opt.Password.RequireUppercase = false; opt.Password.RequireNonAlphanumeric = false; opt.Password.RequiredLength = 6; opt.SignIn.RequireConfirmedEmail = false; opt.Lockout.AllowedForNewUsers = true; opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10); opt.Lockout.MaxFailedAccessAttempts = 5; }) .AddTokenProvider>(TokenOptions.DefaultProvider) .AddRoles() .AddRoleManager>() .AddSignInManager>() .AddRoleValidator>() .AddEntityFrameworkStores(); var oidcSettings = Configuration.OidcSettings; var auth = services.AddAuthentication(DynamicHybrid) .AddPolicyScheme(DynamicHybrid, JwtBearerDefaults.AuthenticationScheme, options => { var enabled = oidcSettings.Enabled; options.ForwardDefaultSelector = ctx => { if (!enabled) return LocalIdentity; if (ctx.Request.Path.StartsWithSegments(OidcCallback) || ctx.Request.Path.StartsWithSegments(OidcLogoutCallback)) { return OpenIdConnect; } if (ctx.Request.Headers.Authorization.Count != 0) { return LocalIdentity; } if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName)) { return OpenIdConnect; } return LocalIdentity; }; }); if (oidcSettings.Enabled) { services.SetupOpenIdConnectAuthentication(auth, oidcSettings, environment); } 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.AddAuthorizationBuilder() .AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)) .AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)) .AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); return services; } private static void SetupOpenIdConnectAuthentication(this IServiceCollection services, AuthenticationBuilder auth, Configuration.OpenIdConnectSettings settings, IWebHostEnvironment environment) { var isDevelopment = environment.IsEnvironment(Environments.Development); var baseUrl = Configuration.BaseUrl; var apiPrefix = baseUrl + "api"; var hubsPrefix = baseUrl + "hubs"; services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme).Configure((options, store) => { options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; options.Cookie.MaxAge = TimeSpan.FromDays(7); options.SessionStore = store; if (isDevelopment) { options.Cookie.Domain = null; } options.Events = new CookieAuthenticationEvents { OnValidatePrincipal = async ctx => { var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); var user = await oidcService.RefreshCookieToken(ctx); if (user != null) { var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user!, false); ctx.ReplacePrincipal(new ClaimsPrincipal(new ClaimsIdentity(claims, ctx.Scheme.Name))); } }, OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }, }; }); auth.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme); auth.AddOpenIdConnect(OpenIdConnect, options => { options.Authority = settings.Authority; options.ClientId = settings.ClientId; options.ClientSecret = settings.Secret; options.RequireHttpsMetadata = options.Authority.StartsWith("https://"); options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.ResponseType = OpenIdConnectResponseType.Code; options.CallbackPath = OidcCallback; options.SignedOutCallbackPath = OidcLogoutCallback; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("offline_access"); options.Scope.Add("roles"); options.Scope.Add("email"); foreach (var customScope in settings.CustomScopes) { options.Scope.Add(customScope); } options.Events = new OpenIdConnectEvents { OnTokenValidated = OidcClaimsPrincipalConverter, OnAuthenticationFailed = ctx => { ctx.Response.Redirect(baseUrl + "login?skipAutoLogin=true&error=" + Uri.EscapeDataString(ctx.Exception.Message)); ctx.HandleResponse(); return Task.CompletedTask; }, OnRedirectToIdentityProviderForSignOut = ctx => { if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.PostLogoutRedirectUri)) { ctx.ProtocolMessage.PostLogoutRedirectUri = ctx.ProtocolMessage.PostLogoutRedirectUri.Replace("http://", "https://"); } return Task.CompletedTask; }, OnRedirectToIdentityProvider = ctx => { // Intercept redirects on API requests and instead return 401 // These redirects are auto login when .NET finds a cookie that it can't match inside the cookie store. I.e. after a restart if (ctx.Request.Path.StartsWithSegments(apiPrefix) || ctx.Request.Path.StartsWithSegments(hubsPrefix)) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; ctx.HandleResponse(); return Task.CompletedTask; } if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.RedirectUri)) { ctx.ProtocolMessage.RedirectUri = ctx.ProtocolMessage.RedirectUri.Replace("http://", "https://"); } return Task.CompletedTask; }, }; }); } /// /// Called after the redirect from the OIDC provider, tries matching the user and update the principal /// to have the correct claims and properties. This is required to later auto refresh; and ensure .NET knows which /// Kavita roles the user has /// /// private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx) { if (ctx.Principal == null) return; var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); var user = await oidcService.LoginOrCreate(ctx.Request, ctx.Principal); if (user == null) { throw new KavitaException("errors.oidc.no-account"); } var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user); var tokens = CopyOidcTokens(ctx); var identity = new ClaimsIdentity(claims, ctx.Scheme.Name); var principal = new ClaimsPrincipal(identity); ctx.Properties ??= new AuthenticationProperties(); ctx.Properties.StoreTokens(tokens); ctx.HttpContext.User = principal; ctx.Principal = principal; ctx.Success(); } /// /// Copy tokens returned by the OIDC provider that we require later /// /// /// private static List CopyOidcTokens(TokenValidatedContext ctx) { if (ctx.TokenEndpointResponse == null) { return []; } var tokens = new List(); if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken)) { tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken }); } else { var logger = ctx.HttpContext.RequestServices.GetRequiredService>(); logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user"); } if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken)) { tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken }); } if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn)) { var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn)); tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") }); } return tokens; } 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; } }