#nullable enable using System; using System.IdentityModel.Tokens.Jwt; 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 { /// /// Returns the user authenticated with OpenID Connect /// /// /// /// if any requirements aren't met Task LoginOrCreate(ClaimsPrincipal principal); /// /// Updates roles, library access and age rating. Will not modify the default admin /// /// /// /// Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user); /// /// Remove from all users /// /// Task ClearOidcIds(); } public class OidcService(ILogger logger, UserManager userManager, IUnitOfWork unitOfWork, IMapper mapper, IAccountService accountService): IOidcService { private const string LibraryAccessPrefix = "library-"; private const string AgeRatingPrefix = "age-rating-"; public async Task LoginOrCreate(ClaimsPrincipal principal) { var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(externalId)) throw new KavitaException("errors.oidc.missing-external-id"); var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); if (user != null) return user; var email = principal.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrEmpty(email)) throw new KavitaException("errors.oidc.missing-email"); if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail()) throw new KavitaException("errors.oidc.email-not-verified"); user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); if (user != null) { logger.LogInformation("User {Name} has matched on email to {ExternalId}", user.UserName, externalId); user.ExternalId = externalId; await unitOfWork.CommitAsync(); return user; } // Cannot match on native account, try and create new one if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0) throw new KavitaException("errors.oidc.role-not-assigned"); user = await NewUserFromOpenIdConnect(settings, principal, externalId); if (user == null) return null; var roles = await userManager.GetRolesAsync(user); if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) throw new KavitaException("errors.oidc.disabled-account"); return user; } public async Task ClearOidcIds() { var users = await unitOfWork.UserRepository.GetAllUsersAsync(); foreach (var user in users) { user.ExternalId = null; } await unitOfWork.CommitAsync(); } private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId) { if (!settings.ProvisionAccounts) return null; var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email); if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null; var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Name); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname); name ??= emailClaim.Value; var other = await userManager.FindByNameAsync(name); if (other != null) { // We match by email, so this will always be unique name = emailClaim.Value; } logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId); // 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).ToList()); throw new KavitaException("errors.oidc.creating-user"); } if (settings.RequireVerifiedEmail) { // Email has been verified by OpenID Connect provider var token = await userManager.GenerateEmailConfirmationTokenAsync(user); await userManager.ConfirmEmailAsync(user, token); } user.ExternalId = externalId; user.Owner = AppUserOwner.OpenIdConnect; AddDefaultStreamsToUser(user, mapper); await AddDefaultReadingProfileToUser(user); await SyncUserSettings(settings, claimsPrincipal, user); await SetDefaults(settings, user); await unitOfWork.CommitAsync(); return user; } private async Task SetDefaults(OidcConfigDto settings, AppUser user) { if (settings.SyncUserSettings) return; // Assign roles var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user"); // Assign libraries await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole)); // Assign age rating user.AgeRestriction = settings.DefaultAgeRating; user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns; await unitOfWork.CommitAsync(); } public async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) { if (!settings.SyncUserSettings) return; var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); if (defaultAdminUser.Id == user.Id) 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.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 && !hasAdminRole) return; var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList(); await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole); } private static void SyncAgeRating(ClaimsPrincipal claimsPrincipal, AppUser user) { var ageRatings = claimsPrincipal .FindAll(ClaimTypes.Role) .Where(r => r.Value.StartsWith(AgeRatingPrefix)) .Select(r => r.Value.TrimPrefix(AgeRatingPrefix)) .ToList(); if (ageRatings.Count == 0) return; var highestAgeRating = AgeRating.Unknown; 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)) { user.DashboardStreams.Add(newStream); } foreach (var stream in Seed.DefaultSideNavStreams.Select(mapper.Map)) { user.SideNavStreams.Add(stream); } } private async Task AddDefaultReadingProfileToUser(AppUser user) { var profile = new AppUserReadingProfileBuilder(user.Id) .WithName("Default Profile") .WithKind(ReadingProfileKind.Default) .Build(); unitOfWork.AppUserReadingProfileRepository.Add(profile); await unitOfWork.CommitAsync(); } }