Authority url validator

This commit is contained in:
Amelia 2025-06-30 14:33:10 +02:00
parent 5480df4cfb
commit 1180d518a2
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
9 changed files with 103 additions and 16 deletions

View file

@ -27,7 +27,7 @@ public class SettingsServiceTests
_mockUnitOfWork = Substitute.For<IUnitOfWork>(); _mockUnitOfWork = Substitute.For<IUnitOfWork>();
_settingsService = new SettingsService(_mockUnitOfWork, ds, _settingsService = new SettingsService(_mockUnitOfWork, ds,
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
Substitute.For<ILogger<SettingsService>>()); Substitute.For<ILogger<SettingsService>>(), Substitute.For<IOidcService>());
} }
#region UpdateMetadataSettings #region UpdateMetadataSettings

View file

@ -1,6 +1,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs.Settings; using API.DTOs.Settings;
using API.Services;
using AutoMapper; using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -8,10 +9,11 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers; namespace API.Controllers;
[AllowAnonymous] public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork,
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork, IMapper mapper): BaseApiController IMapper mapper, ISettingsService settingsService): BaseApiController
{ {
[AllowAnonymous]
[HttpGet("config")] [HttpGet("config")]
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig() public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
{ {
@ -19,4 +21,16 @@ public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWo
return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig)); return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig));
} }
[Authorize("RequireAdminRole")]
[HttpPost("is-valid-authority")]
public async Task<ActionResult<bool>> IsValidAuthority([FromBody] IsValidAuthorityBody authority)
{
return Ok(await settingsService.IsValidAuthority(authority.Authority));
}
public class IsValidAuthorityBody
{
public string Authority { get; set; }
}
} }

View file

@ -141,7 +141,7 @@ public static class IdentityServiceExtensions
options.Events = new JwtBearerEvents options.Events = new JwtBearerEvents
{ {
OnMessageReceived = SetTokenFromQuery OnMessageReceived = SetTokenFromQuery,
}; };
}); });
@ -164,8 +164,12 @@ public static class IdentityServiceExtensions
if (ctx.Principal == null) return; if (ctx.Principal == null) return;
var user = await oidcService.LoginOrCreate(ctx.Principal); var user = await oidcService.LoginOrCreate(ctx.Principal);
if (user == null) return; if (user == null)
{
ctx.Principal = null;
await ctx.HttpContext.SignOutAsync(OpenIdConnect);
return;
}
var claims = new List<Claim> var claims = new List<Claim>
{ {

View file

@ -27,6 +27,11 @@ public interface IOidcService
/// <returns></returns> /// <returns></returns>
/// <exception cref="KavitaException">if any requirements aren't met</exception> /// <exception cref="KavitaException">if any requirements aren't met</exception>
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal); Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
/// <summary>
/// Remove <see cref="AppUser.ExternalId"/> from all users
/// </summary>
/// <returns></returns>
Task ClearOidcIds();
} }
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager, public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
@ -64,7 +69,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.ExternalId = externalId; user.ExternalId = externalId;
//await SyncUserSettings(settings, principal, user); await SyncUserSettings(settings, principal, user);
var roles = await userManager.GetRolesAsync(user); var roles = await userManager.GetRolesAsync(user);
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole))
@ -73,6 +78,17 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
return user; 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<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal) private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal)
{ {
if (!settings.ProvisionAccounts) return null; if (!settings.ProvisionAccounts) return null;
@ -133,10 +149,12 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
var userRoles = await userManager.GetRolesAsync(user); var userRoles = await userManager.GetRolesAsync(user);
if (userRoles.Contains(PolicyConstants.AdminRole)) return; if (userRoles.Contains(PolicyConstants.AdminRole)) return;
await SyncRoles(claimsPrincipal, user); await SyncRoles(claimsPrincipal, user);
await SyncLibraries(claimsPrincipal, user); await SyncLibraries(claimsPrincipal, user);
SyncAgeRating(claimsPrincipal, user); SyncAgeRating(claimsPrincipal, user);
if (unitOfWork.HasChanges()) if (unitOfWork.HasChanges())
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
} }

View file

@ -17,6 +17,7 @@ using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace API.Services; namespace API.Services;
@ -24,6 +25,12 @@ public interface ISettingsService
{ {
Task<MetadataSettingsDto> UpdateMetadataSettings(MetadataSettingsDto dto); Task<MetadataSettingsDto> UpdateMetadataSettings(MetadataSettingsDto dto);
Task<ServerSettingDto> UpdateSettings(ServerSettingDto updateSettingsDto); Task<ServerSettingDto> UpdateSettings(ServerSettingDto updateSettingsDto);
/// <summary>
/// Check if the server can reach the authority at the given uri
/// </summary>
/// <param name="authority"></param>
/// <returns></returns>
Task<bool> IsValidAuthority(string authority);
} }
@ -34,16 +41,18 @@ public class SettingsService : ISettingsService
private readonly ILibraryWatcher _libraryWatcher; private readonly ILibraryWatcher _libraryWatcher;
private readonly ITaskScheduler _taskScheduler; private readonly ITaskScheduler _taskScheduler;
private readonly ILogger<SettingsService> _logger; private readonly ILogger<SettingsService> _logger;
private readonly IOidcService _oidcService;
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
ILogger<SettingsService> logger) ILogger<SettingsService> logger, IOidcService oidcService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_directoryService = directoryService; _directoryService = directoryService;
_libraryWatcher = libraryWatcher; _libraryWatcher = libraryWatcher;
_taskScheduler = taskScheduler; _taskScheduler = taskScheduler;
_logger = logger; _logger = logger;
_oidcService = oidcService;
} }
/// <summary> /// <summary>
@ -347,7 +356,7 @@ public class SettingsService : ISettingsService
return updateSettingsDto; return updateSettingsDto;
} }
private async Task<bool> IsValidAuthority(string authority) public async Task<bool> IsValidAuthority(string authority)
{ {
if (string.IsNullOrEmpty(authority)) if (string.IsNullOrEmpty(authority))
{ {
@ -357,8 +366,8 @@ public class SettingsService : ISettingsService
var url = authority + "/.well-known/openid-configuration"; var url = authority + "/.well-known/openid-configuration";
try try
{ {
var resp = await url.GetAsync(); await url.GetJsonAsync<OpenIdConnectConfiguration>();
return resp.StatusCode == 200; return true;
} }
catch (Exception e) catch (Exception e)
{ {
@ -413,6 +422,8 @@ public class SettingsService : ISettingsService
setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty; setting.Value = updateSettingsDto.OidcConfig.Authority + string.Empty;
Configuration.OidcAuthority = setting.Value; Configuration.OidcAuthority = setting.Value;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
await _oidcService.ClearOidcIds();
} }
if (setting.Key == ServerSettingKey.OidcClientId && if (setting.Key == ServerSettingKey.OidcClientId &&

View file

@ -21,6 +21,14 @@
<input id="oid-authority" class="form-control" <input id="oid-authority" class="form-control"
formControlName="authority" type="text" formControlName="authority" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched"> [class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-authority-validations" class="invalid-feedback">
@if (formControl.errors?.invalidUri) {
<div>{{t('invalidUri')}}</div>
}
</div>
}
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }

View file

@ -2,6 +2,8 @@ import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {ServerSettings} from "../_models/server-settings"; import {ServerSettings} from "../_models/server-settings";
import { import {
AbstractControl,
AsyncValidatorFn,
FormControl, FormControl,
FormGroup, FormGroup,
ReactiveFormsModule, ReactiveFormsModule,
@ -12,6 +14,7 @@ import {SettingsService} from "../settings.service";
import {OidcConfig} from "../_models/oidc-config"; import {OidcConfig} from "../_models/oidc-config";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {map, of} from "rxjs";
@Component({ @Component({
selector: 'app-manage-open-idconnect', selector: 'app-manage-open-idconnect',
@ -42,9 +45,7 @@ export class ManageOpenIDConnectComponent implements OnInit {
this.serverSettings = data; this.serverSettings = data;
this.oidcSettings = this.serverSettings.oidcConfig; this.oidcSettings = this.serverSettings.oidcConfig;
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, [], [this.authorityValidator()]));
// TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, []));
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')])); this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, [])); this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, []));
@ -72,6 +73,31 @@ export class ManageOpenIDConnectComponent implements OnInit {
}) })
} }
authorityValidator(): AsyncValidatorFn {
return (control: AbstractControl) => {
let uri: string = control.value;
if (!uri || uri.trim().length === 0) {
return of(null);
}
try {
new URL(uri);
} catch {
return of({'invalidUri': {'uri': uri}} as ValidationErrors)
}
if (uri.endsWith('/')) {
uri = uri.substring(0, uri.length - 1);
}
return this.settingsService.ifValidAuthority(uri).pipe(map(ok => {
if (ok) return null;
return {'invalidUri': {'uri': uri}} as ValidationErrors;
}));
}
}
requiredIf(other: string): ValidatorFn { requiredIf(other: string): ValidatorFn {
return (control): ValidationErrors | null => { return (control): ValidationErrors | null => {
const otherControl = this.settingsForm.get(other); const otherControl = this.settingsForm.get(other);

View file

@ -78,6 +78,11 @@ export class SettingsService {
isValidCronExpression(val: string) { isValidCronExpression(val: string) {
if (val === '' || val === undefined || val === null) return of(false); if (val === '' || val === undefined || val === null) return of(false);
return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true')); return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true'));
}
ifValidAuthority(authority: string) {
if (authority === '' || authority === undefined || authority === null) return of(false);
return this.http.post<boolean>(this.baseUrl + 'oidc/is-valid-authority', {authority});
} }
} }

View file

@ -17,10 +17,11 @@
"settings": { "settings": {
"save": "{{common.save}}", "save": "{{common.save}}",
"notice": "Notice", "notice": "Notice",
"restart-required": "Changing OpenID Connect settings requires a restart", "restart-required": "Changing OpenID Connect Authority requires a restart",
"provider": "Provider", "provider": "Provider",
"behaviour": "Behaviour", "behaviour": "Behaviour",
"field-required": "{{name}} is required when {{other}} is set", "field-required": "{{name}} is required when {{other}} is set",
"invalidUri": "The provider URL is not valid",
"authority": "Authority", "authority": "Authority",
"authority-tooltip": "The URL to your OpenID Connect provider", "authority-tooltip": "The URL to your OpenID Connect provider",