We have a high priority, elusive, intermittent issue on our prod system where users are getting logged out at some point while using the system while using our "public site". The public site is our own angular front end using ABP backend project + separated ABP Auth Server. The "admin site" is the normal ABP angular front end that we've built on.
There are two noted instances of the issue that I will mention:
/connect/token
requests every 3 mins 45 seconds or so. What I note about this time is that two /connect/token requests were made at exactly the same time (shown in our AbpAuditLogs
at the time that it failed.(failed)net::ERR_NETWORK_CHANGED
on a /connect/token
request on two tabs. It navigated me to the login page. On the remaining 43 tabs, calls to https://<redacted>/connect/logout?post_logout_redirect_uri=https%3A%2F%2F<redacted>
were made and they all navigated me to the login page over the next ~5 mins.In both instances of this issue, I note that there were errors in the Auth server logs, HOWEVER, it's worth noting that when I was performing the experiment, I was seeing this error on prod 14 times per minute, which equates to this happening nearly every time token requests were made (generally volume on prod is not extreme) from my 45 tabs, without me experiencing the logout issue.
An exception occurred in the database while saving changes for context type '"Volo.Abp.OpenIddict.EntityFrameworkCore.OpenIddictProDbContext"'."""Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions. at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithRowsAffectedOnlyAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Update.Internal.SqlServerModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)"
I can see some related questions:
However, AbpEfCoreNavigationHelper
is not available in ABP v7.3.3. It was added in March 2024 which is after the v7.3 release I believe. Are there alternatives for v7.3.3?
This is our angular auth class that wraps over the abp auth service.
import {AuthService, ConfigStateService, LoginParams} from "@abp/ng.core";
import {Injectable} from '@angular/core';
import {ActivatedRoute, Params} from "@angular/router";
import {firstValueFrom} from "rxjs";
import {<redacted>LocalStorageService} from "../<redacted>-local-storage.service";
import {AuthConfigService} from "./auth-config.service";
import {LoginType} from "./login-type";
const socialLoginCallbackQueryParams = "social-login-callback-query-params";
const impersonationCallbackQueryParams = "impersonation-callback-query-params";
/**
* Wrapper around AuthService
* DO NOT USE AuthService directly or it may logout
* using a login flow that was NOT the one used to log in
*/
@Injectable({
providedIn: 'root',
})
export class <redacted>AuthService {
constructor(
private authService: AuthService,
private route: ActivatedRoute,
private localstorage: <redacted>LocalStorageService,
private authConfigService: AuthConfigService,
private configStateService: ConfigStateService
) {
}
get cachedSocialLoginQueryParams(): Params {
return this.localstorage.getItem(socialLoginCallbackQueryParams);
}
get localStorageImpersonationQueryParams(): Params {
return this.localstorage.getItem(impersonationCallbackQueryParams);
}
public async init(loginType?: LoginType): Promise<any> {
await this.configureAndInitAsync(loginType);
}
public async isAuthenticated(): Promise<boolean> {
await this.configureAndInitAsync();
return this.authService.isAuthenticated;
}
public async logout(): Promise<void> {
// if we're impersonating, we don't want to log out of the auth server (code) otherwise we can't initiate new
// impersonation sessions without needing to log in again, but rather just logout just for the public site
// - thus force password response type
await this.configureAndInitAsync(this.authConfigService.currentLoginType === LoginType.Impersonation
? LoginType.Password : undefined);
await firstValueFrom(this.authService.logout());
}
public async passwordLogin(params: LoginParams): Promise<any> {
await this.configureAndInitAsync(LoginType.Password);
return await firstValueFrom(this.authService.login(params));
}
public async socialLogin(provider: string): Promise<void> {
await this.configureAndInitAsync(LoginType.Social);
let queryParams: Params = await firstValueFrom(this.route.queryParams);
this.localstorage.setItem(socialLoginCallbackQueryParams, queryParams);
this.authService.navigateToLogin({
"IsSocialLogin": true,
"Provider": provider ?? undefined,
});
}
public async navigateToLoginForImpersonation(queryParams?: Params): Promise<void> {
await this.configureAndInitAsync(LoginType.Impersonation);
this.authService.navigateToLogin(queryParams);
}
public deleteCachedSocialLoginQueryParams(): void {
this.localstorage.removeItem(socialLoginCallbackQueryParams);
}
public deleteLocalStorageImpersonationQueryParams(): void {
this.localstorage.removeItem(impersonationCallbackQueryParams);
}
public async setLocalStorageImpersonationQueryParams(): Promise<void> {
let queryParams: Params = await firstValueFrom(this.route.queryParams);
this.localstorage.setItem(impersonationCallbackQueryParams, queryParams);
}
private async configureAndInitAsync(loginType?: LoginType): Promise<void> {
let hasChanges: boolean = this.authConfigService.configureAuth(loginType);
if (hasChanges) {
// Only call 'init' if the auth environment config has changed, since we can't cancel the refresh token
// subscription. Re-initializing without changes results in extra 'connect/token' requests, leading to errors on
// the auth server due to concurrency
await this.authService.init();
// Refresh app state to reflect any session changes (e.g. newly acquired token)
this.configStateService.refreshAppState();
}
}
}
and a relevant HTTP_INTERCEPTORS, but according to the AuditLogs, neither the customer nor me had a 400 error on connect/token (in my case, it never reached the Auth server and in the customer's case, they were both 200s):
import {Injectable} from '@angular/core';
import {
HttpEvent,
HttpInterceptor,
HttpHandler,
HttpRequest,
HttpErrorResponse,
} from '@angular/common/http';
import {Observable, tap} from 'rxjs';
import {EnvironmentService} from "@abp/ng.core";
import {Router} from "@angular/router";
import {<redacted>AuthService} from "./shared/auth/<redacted>-auth.service";
@Injectable()
export class ErrorHandlingInterceptor implements HttpInterceptor {
private readonly tokenUrl: string;
constructor(
private <redacted>AuthService: <redacted>AuthService,
private router: Router,
environmentService: EnvironmentService
) {
let issuerUrl = environmentService.getEnvironment()['oAuthConfig']['issuer'];
this.tokenUrl = this.ensureEndsWithSlash(issuerUrl) + 'connect/token';
}
private ensureEndsWithSlash(str: string): string {
let slash = '/';
if (str.endsWith(slash)) {
return str;
}
return str + slash;
}
intercept(
request: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap({
next: _ => {
},
error: async (err: HttpErrorResponse) => {
// Account lockout
if (err.url == this.tokenUrl
&& err.status == 400
&& await this.<redacted>AuthService.isAuthenticated()) {
await this.<redacted>AuthService.logout()
await this.router.navigate([''], { queryParamsHandling: 'preserve' });
}
}
}));
}
}
Do you have any ideas about solving this issue please?
Thanks, Matt
I am trying to implement a system where the user is sent a OTP to their email address that they can use to login without needing their password. This article doesn't quite follow our use case. Our solution has:
This is what I've done so far, based on the article mentioned above:
It all works up until my stage 3, including validating the OTP token and updating the user's security stamp, but the SignInManager.SignInAsync(user, isPersistent: false)
call doesn't log the user into our public site (where this endpoint is being called from) according to angular AuthService.IsAuthenticated
. I've also tried using other authenticationMethod
s, such as OidcConstants.AuthenticationMethods.OneTimePassword
, but without success.
What the SignInAsync method does do is provide a Set-Cookie
for .AspNetCore.Identity.Application
.
Any tips on how to progress? Cheers.
My AppService (which is wrapped in a Controller with HttpPost
and Route("login")
attributes):
using System;
using System.Threading.Tasks;
using IdentityModel;
using MyCompany.MyProject.Email;
using Microsoft.AspNetCore.Authorization;
using OpenIddict.Abstractions;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Identity;
using Volo.Abp.Identity.AspNetCore;
namespace MyCompany.MyProject.PasswordlessLogin;
public class PasswordlessLoginAppService : ApplicationService, IPasswordlessLoginAppService
{
private readonly IMyProjectAuthServerEmailManager _emailManager;
private readonly IdentityUserManager _userManager;
private readonly AbpSignInManager _signInManager;
public PasswordlessLoginAppService(IMyProjectAuthServerEmailManager emailManager,
IdentityUserManager userManager, AbpSignInManager signInManager)
{
_emailManager = emailManager;
_userManager = userManager;
_signInManager = signInManager;
}
// [AllowAnonymous]
public async Task SendOtpEmail(SendOtpEmailInputDto input)
{
var user = await _userManager.FindByEmailAsync(input.Email);
if (user is null)
{
throw new EntityNotFoundException(typeof(IdentityUser));
}
var token = await _userManager.GenerateUserTokenAsync(user, tokenProvider: "PasswordlessLoginProvider",
purpose: "passwordless-auth");
await _emailManager.SendOtpEmailAsync(new SendOtpEmailInput()
{
Email = user.Email,
Token = token,
});
}
public async Task Login(PasswordlessLoginInputDto input)
{
var user = await _userManager.FindByEmailAsync(input.Email);
if (user is null)
{
throw new EntityNotFoundException(typeof(IdentityUser));
}
var isValid = await _userManager.VerifyUserTokenAsync(user, "PasswordlessLoginProvider", "passwordless-auth", input.Token);
if (!isValid)
{
throw new UnauthorizedAccessException("The token " + input.Token + " is not valid for the user " + input.Email);
}
await _userManager.UpdateSecurityStampAsync(user);
await _signInManager.SignInAsync(user, isPersistent: false, authenticationMethod: OidcConstants.AuthenticationMethods.OneTimePassword);
}
}
Please let us know when this will be fixed + refund the question. Cheers.
Bug raised here https://support.abp.io/QA/Questions/5126/Bug---Should-change-password-on-next-login-should-enforce-password-to-be-different should have been fixed in 'the preview version for 7.3' but the issue is replicable both in v7.3.2 and the ABP commercial v7.4.0 as of 14/8/23.
Please let us know when this will be fixed + refund the question. Cheers.
Please let us know when this will be fixed + refund the question. Cheers.
Please let us know when this will be fixed + refund the question. Cheers.
Similar checks need to be made when attempting to change either username or password since no username should be identical with another user's email address (and vice versa). However it should be maintained that the username and email address of an individual user should be able to be the same. Please refund the question. Thanks.
Potential solutions:
EmailConfirmationModel
make sure the ReturnUrl
is set to the _appUrlProvider.GetUrlAsync("Angular")
(e.g. for us that would be something like admin.localhost:4200)Thanks.
I can't really see any justification to suggest this isn't a bug since forcing a user to change password on next login is something used for security. Untested: Does this affect the 'Force users to periodically change password' feature as well?