使用身份服务器4注销时不稳定 [英] Erratic Signing Out with IdentityServer 4

查看:14
本文介绍了使用身份服务器4注销时不稳定的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我们有用户抱怨,因为他们在工作进行到一半时被重定向到Identity Server的登录页面(从而丢失了当前工作)。我们已尝试配置滑动过期,所以我不确定为什么会发生这种情况。

我意识到这篇文章中有相当多的代码。但有很多活动部件,我想给出尽可能多的信息。

这种行为是随机的,很难报告准确的可重现事件。在我的测试中,我被随机弹出,很难理解这是否与我设置的任何配置有任何关系。在我看来,我根本不应该被驱逐,因为在addAccessTokenExpiring事件期间总是会发送静默登录。

我们拥有的设置是:

  • IdP(使用身份服务器4)
  • 客户端APP,使用Vue.js(使用TypeScrip)实现
  • 用ASP.NET Core 5编写的API

我们编写的配置和身份验证服务是:
auth.config.ts

import { Log, UserManagerSettings, WebStorageStateStore } from "oidc-client";
import AppConfig from "./invariable/app.config";
/* eslint-disable */
class AuthConfig {
    public settings: UserManagerSettings;
    private baseUrl: string;

    constructor() {
        this.baseUrl = AppConfig.RunTimeConfig.VUE_APP_APPURL || process.env.VUE_APP_APPURL;

        this.settings = {
            userStore: new WebStorageStateStore({ store: window.localStorage }),
            authority: AppConfig.RunTimeConfig.VUE_APP_IDPURL || process.env.VUE_APP_IDPURL,
            client_id: AppConfig.RunTimeConfig.VUE_APP_CLIENTID || process.env.VUE_APP_CLIENTID,
            client_secret: AppConfig.RunTimeConfig.VUE_APP_CLIENTSECRET || process.env.VUE_APP_CLIENTSECRET,
            redirect_uri: this.baseUrl + process.env.VUE_APP_AUTHCALLBACK,
            automaticSilentRenew: false,
            silent_redirect_uri: this.baseUrl + process.env.VUE_APP_SILENTREFRESH,
            response_type: "code",
            response_mode: "query",
            scope: "our_scopes",
            post_logout_redirect_uri: this.baseUrl + process.env.VUE_APP_SIGNOUT_CALLBACK,
            filterProtocolClaims: true,
            loadUserInfo: true,
            revokeAccessTokenOnSignout: true,
            staleStateAge: 300, // should match access_token lifetime.
        };
    }
}
/* eslint-enable */

const authConfig = new AuthConfig();

export default authConfig;

auth.service.ts

import { UserManagerSettings, User, UserManager } from "oidc-client";
import authConfig from "@/config/auth.config";
import axios, { AxiosResponse } from "axios";
import { Ajax } from "@/config/invariable/ajax";
import AccessClaim from "@/domain/general/accessclaim";
import _ from "lodash";
import store from "@/store";
import StoreNamespaces from "@/config/invariable/store.namespaces";
import Token from "@/store/token/token";

export class AuthService {
    private userManager: UserManager;
    private tokenStore: string;

    constructor(private settings: UserManagerSettings) {
        this.settings = settings;
        this.userManager = new UserManager(this.settings);
        this.tokenStore = StoreNamespaces.tokenModule;
    }

    public addEvents(): void {
        this.userManager.events.addUserSignedOut(() => {
            this.signInAgain();
        });

        this.userManager.events.addAccessTokenExpired(() => {
            console.log("Token expired");
            this.clearLocalState();
            console.log("Stale state cleaned up");
        });

        this.userManager.events.addAccessTokenExpiring(() => {
            console.log("Access token about to expire.");
            this.signInAgain();
        });

        this.userManager.events.addSilentRenewError(() => {
            // custom logic here
            console.log("An error happened whilst silently renewing the token.");
        });
    }

    public clearLocalState(): Promise<void> {
        return this.userManager.clearStaleState();
    }

    public getUserOnLoad(): Promise<User> {

        return this.userManager.getUser().then((user) => {
            if (!_.isNil(user) && !user.expired) {

                console.log("first load sign-in");
                const decodedIdToken = user.profile;

                if (!_.isNil(decodedIdToken.store) && !_.isArray(decodedIdToken.store)) decodedIdToken.store = [decodedIdToken.store];
                if (!_.isNil(decodedIdToken.classification) && !_.isArray(decodedIdToken.classification)) decodedIdToken.classification = [decodedIdToken.classification];
                if (!_.isNil(decodedIdToken.location) && !_.isArray(decodedIdToken.location)) decodedIdToken.location = [decodedIdToken.location];
                if (!_.isArray(decodedIdToken.app)) decodedIdToken.app = [decodedIdToken.app];

                const token = new Token();
                token.accessToken = user.access_token;
                token.idToken = user.id_token;
                token.storeClaims = decodedIdToken.store || [];
                token.userType = decodedIdToken.usertype;
                token.isLoggedIn = user && !user.expired;
                token.app = decodedIdToken.app;
                token.userName = decodedIdToken.name ?? "Unknown User";

                store.dispatch(`${this.tokenStore}/setToken`, token);

                return user;
            } else {
                return this.signInAgain();
            }
        });
    }

    public async getUserIfLoggedIn(): Promise<User | null> {
        const currentUser: User | null = await this.userManager.getUser();
        const loggedIn = currentUser !== null && !currentUser.expired;

        return loggedIn ? currentUser : null;
    }

    public async isLoggedIn(): Promise<boolean> {
        const currentUser: User | null = await this.userManager.getUser();

        return currentUser !== null && !currentUser.expired;
    }

    public login(): Promise<void> {
        return this.userManager.signinRedirect();
    }

    public logout(): Promise<void> {
        return this.userManager.signoutRedirect();
    }

    public getAccessToken(): Promise<string> {
        return this.userManager.getUser().then((data: any) => {
            return data.access_token;
        });
    }

    public signInAgain(): Promise<User> {

        return this.userManager
            .signinSilent()
            .then((user) => {

                console.log("silent sign-in");
                const decodedIdToken = user.profile;

                if (!_.isNil(decodedIdToken.store) && !_.isArray(decodedIdToken.store)) decodedIdToken.store = [decodedIdToken.store];
                if (!_.isNil(decodedIdToken.classification) && !_.isArray(decodedIdToken.classification)) decodedIdToken.classification = [decodedIdToken.classification];
                if (!_.isNil(decodedIdToken.location) && !_.isArray(decodedIdToken.location)) decodedIdToken.location = [decodedIdToken.location];
                if (!_.isArray(decodedIdToken.app)) decodedIdToken.app = [decodedIdToken.app];

                const token = new Token();
                token.accessToken = user.access_token;
                token.idToken = user.id_token;
                token.storeClaims = decodedIdToken.store || [];
                token.userType = decodedIdToken.usertype;
                token.isLoggedIn = user && !user.expired;
                token.app = decodedIdToken.app;
                token.userName = decodedIdToken.name ?? "Unknown User";

                store.dispatch(`${this.tokenStore}/setToken`, token);

                return user;
            })
            .catch((err) => {
                console.log("silent error");
                console.log(err);

                this.login();
                return err;
            });
    }

    public getAccessClaims(userDetails: any): Promise<AxiosResponse<any>> {
        return axios.post(`${Ajax.appApiBase}/PermittedUse/GetAccessesForUser`, userDetails).then((resp: AxiosResponse<any>) => {
            return resp.data;
        });
    }

    public getPermissions(userDetails: any, siteId: number | null): Promise<AxiosResponse<any>> {
        return axios.get(`${Ajax.appApiBase}/PermittedUse/GetPermissions/${siteId ?? 0}`).then((resp: AxiosResponse<any>) => {
            return resp.data;
        });
    }

    public constructAccess(userType: string, claims: Array<AccessClaim>): Array<AccessClaim> {
        switch (userType) {
            case "storeadmin":
            case "storeuser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "store";
                });
            case "warduser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "classification";
                });
        }

        return Array<AccessClaim>();
    }

    public getBookableLocations(userType: string, claims: Array<AccessClaim>): Array<AccessClaim> {
        switch (userType) {
            case "storeadmin":
            case "storeuser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "store";
                });
            case "warduser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "location";
                });
        }

        return Array<AccessClaim>();
    }
}

export const authService = new AuthService(authConfig.settings);

在IdP上,我们的客户端配置为:

ClientName = IcClients.Names.ConsumablesApp,
ClientId = IcClients.ConsumablesApp,

RequireConsent = false,
AccessTokenLifetime = TokenConfig.AccessTokenLifetime, // 300 for test purposes
IdentityTokenLifetime = TokenConfig.IdentityTokenLifetime, // 300
AllowOfflineAccess = true,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Sliding,
UpdateAccessTokenClaimsOnRefresh = true,
RequireClientSecret = true,
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,

AllowAccessTokensViaBrowser = true,
AlwaysIncludeUserClaimsInIdToken = true,
RedirectUris = new List<string>
{
    "https://localhost:44336/authcallback.html",
    "https://localhost:8090/authcallback.html",
    "https://localhost:44336/silent-refresh.html",
    "https://localhost:8090/silent-refresh.html"
},
PostLogoutRedirectUris = new List<string>
{
    "https://localhost:44336/signout-callback-oidc.html",
    "https://localhost:8090/signout-callback-oidc.html"
},
AllowedScopes = new List<string>
{
    IdentityServerConstants.StandardScopes.OpenId,
    IdentityServerConstants.StandardScopes.Profile,
    IdentityServerConstants.LocalApi.ScopeName,
    IcAccessScopes.IcAccessClaimsScope,
    IdentityResources.UserDetails,
    IcAccessScopes.ConsumablesScope
},
ClientSecrets = { new Secret("oursecret".Sha256())}

在IdP,我们使用的是ASP.NET核心标识:

services.AddIdentity<IdpUser, IdentityRole<int>>()
    .AddEntityFrameworkStores<IdpDbContext>()
    .AddDefaultTokenProviders();

services.ConfigureApplicationCookie(options =>
{
    options.ExpireTimeSpan = cookieDuration; // set to 1 hour
    options.SlidingExpiration = true;
});

我的预期是滑动窗口应该每隔5分钟延长一次,因为用户应该在令牌过期之前以静默方式再次登录。

在我的开发环境中监视IdP时,我注意到checksession调用仅在用户登录时进行一次。维基说checksession调用应该每两秒发生一次(默认情况下)。我没有更改这个默认设置(不是故意的)。我甚至明确地将checkSessionInterval属性设置为2000,以确保将其设置为2。

我想要介绍的另一件事是静默刷新html文件,因为我意识到CSP的东西可以发挥作用:

<head>
  <title></title>
  <meta http-equiv="Content-Security-Policy" content="frame-src 'self' <%= VUE_APP_IDPURL %>; script-src 'self' 'unsafe-inline' 'unsafe-eval';" />
</head>
<body>
  <script src="./oidc-client.min.js"></script>
  <script>
    (function refresh() {
      window.location.hash = decodeURIComponent(window.location.hash);
      new Oidc.UserManager({
        // eslint-disable-next-line @typescript-eslint/camelcase
        response_mode: "query",
        userStore: new Oidc.WebStorageStateStore({
          store: window.localStorage,
        }),
      })
        .signinSilentCallback()
        .then(function() {
          console.log("****************************************signinSilentCallback****************************************");
        })
        .catch(function(err) {
          debug;
          console.log(err);
        });
    })();
  </script>
</body>

如果有人能说明这一点,我们将不胜感激。

一些进一步的信息。作为测试,我将令牌的refrsh时间和身份cookie的cookie生存期都设置为10小时(36,000秒)。

我仍然收到用户在45分钟后被踢出的报告。

推荐答案

我在解决此问题时得出了两个结论,我不敢说这是我们问题的适当解决方案。

  1. 我的登录代码有误。我使用了IdentityServer4扩展HttpContext.SignInAsync(user, authProperties)来创建会话cookie。如果使用ASP.NET标识,这不是做事情的方法。出于1个原因,它不在Cookie中包括SecurityStamp声明。在他们自己的快速入门中,他们使用SignInManager登录并发布Cookie_signInManager.PasswordSignInAsync(idpUser, model.Password, model.RememberLogin, true)
  2. 我需要关闭ASP.NET标识中的SecurityStamp验证功能。我们已经断定,没有它我们也能活下去。您可能认为这会有一个配置设置,但我找不到它。因此,在这个阶段,我已经将UserManager子类化,并覆盖了SupportsUserSecurityStamp属性,如下所示:public override bool SupportsUserSecurityStamp => false;。理论上,这一功能现在将被关闭。如果不是这样,或者如果有更好的方法来做,我很高兴被纠正。(希望听取ASP.NET团队成员对此的看法)。

就是这样。

这篇关于使用身份服务器4注销时不稳定的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆