Cleanup, nicer flow

This commit is contained in:
Amelia 2025-06-29 20:16:17 +02:00
parent 465723fedf
commit 0b64ea1622
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
15 changed files with 184 additions and 179 deletions

View file

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Settings;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -8,16 +9,14 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers;
[AllowAnonymous]
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork): BaseApiController
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork, IMapper mapper): BaseApiController
{
// TODO: Decide what we want to expose here, not really anything useful in it. But the discussion is needed
// Public endpoint
[HttpGet("config")]
public async Task<ActionResult<OidcConfigDto>> GetOidcConfig()
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
{
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settings.OidcConfig);
return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig));
}
}

View file

@ -0,0 +1,12 @@
#nullable enable
namespace API.DTOs.Settings;
public sealed record OidcPublicConfigDto
{
/// <inheritdoc cref="OidcConfigDto.Authority"/>
public string? Authority { get; set; }
/// <inheritdoc cref="OidcConfigDto.ClientId"/>
public string? ClientId { get; set; }
/// <inheritdoc cref="OidcConfigDto.AutoLogin"/>
public bool AutoLogin { get; set; }
}

View file

@ -123,7 +123,7 @@ public static class IdentityServiceExtensions
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery,
OnTokenValidated = OidcClaimsPrincipalConverter
OnTokenValidated = OidcClaimsPrincipalConverter,
};
});
}

View file

@ -386,7 +386,6 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
}
}

View file

@ -41,21 +41,21 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(externalId))
throw new KavitaException("oidc.errors.missing-external-id");
throw new KavitaException("errors.oidc.missing-external-id");
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences);
if (user != null)
{
// await ProvisionUserSettings(settings, principal, user);
//await SyncUserSettings(settings, principal, user);
return user;
}
var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email))
throw new KavitaException("oidc.errors.missing-email");
throw new KavitaException("errors.oidc.missing-email");
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
throw new KavitaException("oidc.errors.email-not-verified");
throw new KavitaException("errors.oidc.email-not-verified");
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences)
@ -64,11 +64,11 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.ExternalId = externalId;
// await ProvisionUserSettings(settings, principal, user);
//await SyncUserSettings(settings, principal, user);
var roles = await userManager.GetRolesAsync(user);
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole))
throw new KavitaException("oidc.errors.disabled-account");
throw new KavitaException("errors.oidc.disabled-account");
return user;
}
@ -101,7 +101,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
{
logger.LogError("Failed to create new user from OIDC: {Errors}",
res.Errors.Select(x => x.Description).ToString());
throw new KavitaException("oidc.errors.creating-user");
throw new KavitaException("errors.oidc.creating-user");
}
AddDefaultStreamsToUser(user, mapper);
@ -151,7 +151,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
if (roles.Count == 0) return;
var errors = await accountService.UpdateRolesForUser(user, roles);
if (errors.Any()) throw new KavitaException("oidc.errors.syncing-user");
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
}
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)

View file

@ -11,6 +11,7 @@ import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {WikiLink} from "../_models/wiki";
import {OidcService} from "./oidc.service";
/**
* NavItem used to construct the dropdown or NavLinkModal on mobile
@ -34,6 +35,7 @@ interface NavItem {
export class NavService {
private readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
@ -173,12 +175,28 @@ export class NavService {
}
logout() {
this.oidcService.logout();
this.accountService.logout();
this.hideNavBar();
this.hideSideNav();
this.router.navigateByUrl('/login');
}
handleLogin() {
this.showNavBar();
this.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
}
/**
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
*/

View file

@ -1,12 +1,10 @@
import {DestroyRef, Injectable} from '@angular/core';
import {OAuthService} from "angular-oauth2-oidc";
import {DestroyRef, effect, inject, Injectable, signal} from '@angular/core';
import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc";
import {BehaviorSubject, from} from "rxjs";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {OidcConfig} from "../admin/_models/oidc-config";
import {OidcPublicConfig} from "../admin/_models/oidc-config";
import {AccountService} from "./account.service";
import {NavService} from "./nav.service";
import {Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {take} from "rxjs/operators";
@ -15,24 +13,29 @@ import {take} from "rxjs/operators";
})
export class OidcService {
/*
TODO: Further cleanup, nicer handling for the user
See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards
Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts
*/
private readonly oauth2 = inject(OAuthService);
private readonly httpClient = inject(HttpClient);
private readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef);
baseUrl = environment.apiUrl;
settingsSource = new BehaviorSubject<OidcConfig | null>(null);
settings$ = this.settingsSource.asObservable();
constructor(
private oauth2: OAuthService,
private httpClient: HttpClient,
private accountService: AccountService,
private navService: NavService,
private router: Router,
private destroyRef: DestroyRef,
) {
private readonly _ready = signal(false);
public readonly ready = this._ready.asReadonly();
private readonly _settings = signal<OidcPublicConfig | undefined>(undefined);
public readonly settings = this._settings.asReadonly();
constructor() {
// log events in dev
if (!environment.production) {
this.oauth2.events.subscribe(event => {
if (event instanceof OAuthErrorEvent) {
console.error('OAuthErrorEvent Object:', event);
} else {
console.debug('OAuthEvent Object:', event);
}
});
}
this.config().subscribe(oidcSetting => {
if (!oidcSetting.authority) {
@ -42,14 +45,15 @@ export class OidcService {
this.oauth2.configure({
issuer: oidcSetting.authority,
clientId: oidcSetting.clientId,
requireHttps: oidcSetting.authority.startsWith("https://"),
// Require https in production unless localhost
requireHttps: environment.production ? 'remoteOnly' : false,
redirectUri: window.location.origin + "/oidc/callback",
postLogoutRedirectUri: window.location.origin + "/login",
showDebugInformation: true,
showDebugInformation: !environment.production,
responseType: 'code',
scope: "openid profile email roles offline_access",
});
this.settingsSource.next(oidcSetting);
this._settings.set(oidcSetting);
this.oauth2.setupAutomaticSilentRefresh();
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
@ -67,7 +71,7 @@ export class OidcService {
next: success => {
if (!success) return;
this.tryLogin();
this._ready.set(true);
},
error: error => {
console.log(error);
@ -76,52 +80,21 @@ export class OidcService {
})
}
private tryLogin() {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) return;
if (this.token) {
this.accountService.loginByToken(this.token).subscribe({
next: _ => {
this.doLogin();
}
});
}
});
}
oidcLogin() {
login() {
this.oauth2.initLoginFlow();
}
config() {
return this.httpClient.get<OidcConfig>(this.baseUrl + "oidc/config");
}
get token() {
return this.oauth2.getAccessToken();
}
logout() {
this.oauth2.logOut();
}
private doLogin() {
this.navService.showNavBar();
this.navService.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
config() {
return this.httpClient.get<OidcPublicConfig>(this.baseUrl + "oidc/config");
}
get token() {
return this.oauth2.getAccessToken();
}
}

View file

@ -7,3 +7,9 @@ export interface OidcConfig {
provisionUserSettings: boolean;
autoLogin: boolean;
}
export interface OidcPublicConfig {
authority: string;
clientId: string;
autoLogin: boolean;
}

View file

@ -1,7 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
DestroyRef, effect,
HostListener,
inject,
OnInit
@ -100,6 +100,21 @@ export class AppComponent implements OnInit {
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
// Login automatically when a token is available
effect(() => {
const ready = this.oidcService.ready();
if (!ready || !this.oidcService.token) return;
this.accountService.loginByToken(this.oidcService.token).subscribe({
next: () => {
this.navService.handleLogin();
},
error: err => {
console.error(err);
}
});
});
}
@HostListener('window:resize', ['$event'])

View file

@ -48,7 +48,6 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
import {WikiLink} from "../../../_models/wiki";
import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component";
import {MetadataService} from "../../../_services/metadata.service";
import {OidcService} from "../../../_services/oidc.service";
@Component({
selector: 'app-nav-header',
@ -66,7 +65,6 @@ export class NavHeaderComponent implements OnInit {
private readonly searchService = inject(SearchService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly navService = inject(NavService);
@ -136,14 +134,6 @@ export class NavHeaderComponent implements OnInit {
this.cdRef.markForCheck();
}
logout() {
this.accountService.logout();
this.navService.hideNavBar();
this.navService.hideSideNav();
this.oidcService.logout();
this.router.navigateByUrl('/login');
}
moveFocus() {
this.document.getElementById('content')?.focus();
}

View file

@ -1,15 +1,10 @@
<ng-container *transloco="let t; read: 'oidc'">
<app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body>
@if (error.length > 0) {
<div class="invalid-feedback mb-2">
{{t(error)}}
</div>
}
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
</ng-container>
</app-splash-container>
<ng-container *transloco="let t; prefix: 'oidc'">
@if (showSplash()) {
<app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body>
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
</ng-container>
</app-splash-container>
}
</ng-container>

View file

@ -1,11 +1,10 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {ChangeDetectorRef, Component, OnInit, signal} from '@angular/core';
import {SplashContainerComponent} from "../_components/splash-container/splash-container.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Router} from "@angular/router";
import {NavService} from "../../_services/nav.service";
import {take} from "rxjs/operators";
import {OidcService} from "../../_services/oidc.service";
@Component({
selector: 'app-oidc-callback',
@ -16,16 +15,15 @@ import {OidcService} from "../../_services/oidc.service";
templateUrl: './oidc-callback.component.html',
styleUrl: './oidc-callback.component.scss'
})
export class OidcCallbackComponent implements OnInit{
export class OidcCallbackComponent implements OnInit {
error: string = '';
showSplash = signal(false);
constructor(
private accountService: AccountService,
private router: Router,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
private oidcService: OidcService,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
@ -40,6 +38,9 @@ export class OidcCallbackComponent implements OnInit{
this.cdRef.markForCheck();
}
});
// Show back to log in splash only after 1s, for a more seamless experience
setTimeout(() => this.showSplash.set(true), 1000);
}
goToLogin() {

View file

@ -2,8 +2,8 @@
<app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body>
<ng-container *ngIf="isLoaded">
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
<ng-container *ngIf="isLoaded()">
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow()">
<div class="card-text">
<div class="mb-3">
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
@ -22,13 +22,13 @@
</div>
<div class="sign-in">
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting">{{t('submit')}}</button>
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting()">{{t('submit')}}</button>
</div>
</div>
</form>
@if (oidcEnabled) {
<button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.oidcLogin()">{{t('oidc')}}</button>
@if (oidcService.ready()) {
<button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.login()">{{t('oidc')}}</button>
}
</ng-container>

View file

@ -1,4 +1,12 @@
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, computed,
DestroyRef, effect, inject,
OnInit,
signal
} from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
@ -13,6 +21,7 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
import {environment} from "../../../environments/environment";
import {OidcService} from "../../_services/oidc.service";
import {forkJoin} from "rxjs";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
@Component({
@ -24,6 +33,15 @@ import {forkJoin} from "rxjs";
})
export class UserLoginComponent implements OnInit {
private readonly accountService = inject(AccountService);
private readonly router = inject(Router);
private readonly memberService = inject(MemberService);
private readonly toastr = inject(ToastrService);
private readonly navService = inject(NavService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
protected readonly oidcService = inject(OidcService);
baseUrl = environment.apiUrl;
loginForm: FormGroup = new FormGroup({
@ -34,27 +52,31 @@ export class UserLoginComponent implements OnInit {
/**
* If there are no admins on the server, this will enable the registration to kick in.
*/
firstTimeFlow: boolean = true;
firstTimeFlow = signal(true);
/**
* Used for first time the page loads to ensure no flashing
*/
isLoaded: boolean = false;
isSubmitting = false;
oidcEnabled = false;
isLoaded = signal(false);
isSubmitting = signal(false);
/**
* undefined until query params are read
*/
skipAutoLogin = signal<boolean | undefined>(undefined)
constructor(
private accountService: AccountService,
private router: Router,
private memberService: MemberService,
private toastr: ToastrService,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
private route: ActivatedRoute,
protected oidcService: OidcService,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
}
constructor() {
this.navService.hideNavBar();
this.navService.hideSideNav();
effect(() => {
const skipAutoLogin = this.skipAutoLogin();
const oidcConfig = this.oidcService.settings();
if (!oidcConfig || skipAutoLogin === undefined) return;
if (oidcConfig.autoLogin && !skipAutoLogin) {
this.oidcService.login()
}
});
}
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -68,34 +90,24 @@ export class UserLoginComponent implements OnInit {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists;
this.firstTimeFlow.set(!adminExists);
if (this.firstTimeFlow) {
if (this.firstTimeFlow()) {
this.router.navigateByUrl('registration/register');
return;
}
this.isLoaded = true;
this.cdRef.markForCheck();
this.isLoaded.set(true);
});
this.route.queryParamMap.subscribe(params => {
const val = params.get('apiKey');
if (val != null && val.length > 0) {
this.login(val);
return;
}
const skipAutoLogin = params.get('skipAutoLogin') === 'true';
this.oidcService.settings$.subscribe(cfg => {
if (!cfg) return;
this.oidcEnabled = cfg.authority !== "";
this.cdRef.markForCheck();
if (cfg.autoLogin && !skipAutoLogin) {
this.oidcService.oidcLogin()
}
});
this.skipAutoLogin.set(params.get('skipAutoLogin') === 'true')
});
}
@ -104,33 +116,18 @@ export class UserLoginComponent implements OnInit {
login(apiKey: string = '') {
const model = this.loginForm.getRawValue();
model.apiKey = apiKey;
this.isSubmitting = true;
this.cdRef.markForCheck();
this.accountService.login(model).subscribe(() => {
this.loginForm.reset();
this.doLogin()
this.isSubmitting.set(true);
this.accountService.login(model).subscribe({
next: () => {
this.loginForm.reset();
this.navService.handleLogin()
this.isSubmitting = false;
this.cdRef.markForCheck();
}, err => {
this.toastr.error(err.error);
this.isSubmitting = false;
this.cdRef.markForCheck();
this.isSubmitting.set(false);
},
error: (err) => {
this.toastr.error(err.error);
this.isSubmitting.set(false);
}
});
}
private doLogin() {
this.navService.showNavBar();
this.navService.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl('/home');
}
}
}

View file

@ -13,13 +13,6 @@
"oidc": {
"title": "OpenID Connect Callback",
"login": "Back to login screen",
"errors": {
"missing-external-id": "OpenID Connect provider did not return a valid identifier",
"missing-email": "OpenID Connect provider did not return a valid email",
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
"no-account": "No matching account found",
"disabled-account": "This account is disabled, please contact an administrator"
},
"settings": {
"save": "{{common.save}}",
"notice": "Notice",
@ -2455,7 +2448,14 @@
"invalid-password-reset-url": "Invalid reset password url",
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
"theme-manual-upload": "There was an issue creating Theme from manual upload",
"theme-already-in-use": "Theme already exists by that name"
"theme-already-in-use": "Theme already exists by that name",
"oidc": {
"missing-external-id": "OpenID Connect provider did not return a valid identifier",
"missing-email": "OpenID Connect provider did not return a valid email",
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
"no-account": "No matching account found",
"disabled-account": "This account is disabled, please contact an administrator"
}
},
"metadata-builder": {