【问题标题】:Erratic Signing Out with IdentityServer 4IdentityServer 4 不稳定的注销
【发布时间】:2021-06-07 08:50:25
【问题描述】:

我们有用户抱怨,因为他们在工作期间被重定向到 Identity Server 的登录页面(因此丢失了他们当前的工作)。我们已努力配置滑动到期,所以我不确定为什么会发生这种情况。

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

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

我们的设置是:

  • 一个 Idp(使用 IdentityServer 4)
  • 客户端应用,使用 Vue.js(使用 Typescript)实现
  • 用 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 Core Identity:

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

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

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

在我的开发环境中监视 IDP 时,我确实注意到的一件事是 checksession 调用仅在用户登录时进行一次。wiki 说 checksession 调用应该每 2 秒发生一次(默认)。我没有改变这个默认值(不知情)。我什至明确将checkSessionInterval 属性设置为2000,以确保它设置为2s。

我要说明的另一件事是静默刷新 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>

如果有人能对此有所了解,将不胜感激。

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

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

【问题讨论】:

  • 由于重新部署了 IdentityServer 或客户端,您没有退出?
  • @ToreNestenius 否。这发生在正常使用过程中。这不是由于部署了客户端或 IDP。
  • 我只是有一个想法。我是否应该在拨打AddIdentityServer 之后再拨打ConfigureApplicationCookie(这反过来又拨打AddAspNetIdentity)。也许最后一次调用覆盖了我的自定义 cookie 设置。虽然我注意到AddAspNetIdentity 并没有覆盖cookie 的名称,但是当我给它一个不同的名称时。在ConfigureApplicationCookie.
  • 当你涉及到许多不同的服务都想拥有 cookie 时,总是一团糟。理想情况下,我会将 ASP.NET 标识放在它自己的服务中。
  • IdentityServer 不是用户存储。它与用户存储交互,并为这些用户存储实现 OAuth2/OpenIdConnect。 ASP.NET 标识就是这样一种用户存储。我需要 IdentityServer 来处理 OAuth 的东西。

标签: identityserver4 oidc-client-js


【解决方案1】:

我在解决这个问题时得出了 2 个结论,我会犹豫说这是对我们问题的正确解决方案。

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

就是这样。

【讨论】:

    猜你喜欢
    • 2023-03-09
    • 2017-12-03
    • 2019-01-16
    • 1970-01-01
    • 1970-01-01
    • 2019-02-17
    • 2017-04-08
    • 2022-01-12
    • 1970-01-01
    相关资源
    最近更新 更多