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>();
_settingsService = new SettingsService(_mockUnitOfWork, ds,
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
Substitute.For<ILogger<SettingsService>>());
Substitute.For<ILogger<SettingsService>>(), Substitute.For<IOidcService>());
}
#region UpdateMetadataSettings

View file

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Settings;
using API.Services;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -8,10 +9,11 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers;
[AllowAnonymous]
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork, IMapper mapper): BaseApiController
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork,
IMapper mapper, ISettingsService settingsService): BaseApiController
{
[AllowAnonymous]
[HttpGet("config")]
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));
}
[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
{
OnMessageReceived = SetTokenFromQuery
OnMessageReceived = SetTokenFromQuery,
};
});
@ -164,8 +164,12 @@ public static class IdentityServiceExtensions
if (ctx.Principal == null) return;
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>
{

View file

@ -27,6 +27,11 @@ public interface IOidcService
/// <returns></returns>
/// <exception cref="KavitaException">if any requirements aren't met</exception>
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,
@ -46,7 +51,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences);
if (user != null)
{
//await SyncUserSettings(settings, principal, user);
// await SyncUserSettings(settings, principal, user);
return user;
}
@ -64,7 +69,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.ExternalId = externalId;
//await SyncUserSettings(settings, principal, user);
await SyncUserSettings(settings, principal, user);
var roles = await userManager.GetRolesAsync(user);
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole))
@ -73,6 +78,17 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
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)
{
if (!settings.ProvisionAccounts) return null;
@ -133,10 +149,12 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
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();
}

View file

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

View file

@ -21,6 +21,14 @@
<input id="oid-authority" class="form-control"
formControlName="authority" type="text"
[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>
</app-setting-item>
}

View file

@ -2,6 +2,8 @@ import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {ServerSettings} from "../_models/server-settings";
import {
AbstractControl,
AsyncValidatorFn,
FormControl,
FormGroup,
ReactiveFormsModule,
@ -12,6 +14,7 @@ import {SettingsService} from "../settings.service";
import {OidcConfig} from "../_models/oidc-config";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {map, of} from "rxjs";
@Component({
selector: 'app-manage-open-idconnect',
@ -42,9 +45,7 @@ export class ManageOpenIDConnectComponent implements OnInit {
this.serverSettings = data;
this.oidcSettings = this.serverSettings.oidcConfig;
// TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, []));
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, [], [this.authorityValidator()]));
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
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 {
return (control): ValidationErrors | null => {
const otherControl = this.settingsForm.get(other);

View file

@ -78,6 +78,11 @@ export class SettingsService {
isValidCronExpression(val: string) {
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'));
}
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": {
"save": "{{common.save}}",
"notice": "Notice",
"restart-required": "Changing OpenID Connect settings requires a restart",
"restart-required": "Changing OpenID Connect Authority requires a restart",
"provider": "Provider",
"behaviour": "Behaviour",
"field-required": "{{name}} is required when {{other}} is set",
"invalidUri": "The provider URL is not valid",
"authority": "Authority",
"authority-tooltip": "The URL to your OpenID Connect provider",