繁体   English   中英

Blazor WebAssembly + Amazon Cognito

[英]Blazor WebAssembly + Amazon Cognito

我想设置一个Blazor客户端应用程序,通过AWS Cognito进行身份验证。

当我运行应用程序时,我没有重定向到登录页面,而是页面显示“正在授权...”几秒钟,而我在控制台中收到此错误:

The loading of “https://blazorapp.auth.eu-central-1.amazoncognito.com/login?…Q&code_challenge_method=S256&prompt=none&response_mode=query” in a frame is denied by “X-Frame-Options“ directive set to “DENY“.
This error page has no error code in its security info
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed.

然后,默认的“你好,世界!” 显示索引页面(尽管据我了解,根据 App.razor 定义,未经身份验证的用户不应该看到它?) 如果我单击“登录”,我会在控制台中收到相同的错误,但几秒钟后,Cognito 托管的登录页面打开,我可以登录,我被重定向回我的应用程序,并且应用程序显示经过身份验证的用户信息在右上角,但控制台又有点奇怪:

info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed.
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[1]
      Authorization was successful.

问题 1

我怎样才能摆脱这些错误并让我的应用程序重定向到 Cognito 登录页面而不会有约 10 秒的延迟?

问题2

为什么无论我是否经过身份验证,我的应用程序中的所有内容始终可见? 就好像 App.razor 中AuthorizeRouteView下的NotAuthorized节点App.razor没有效果,除非我在这里混淆了一些东西

代码:

程序.cs

builder.Services.AddOidcAuthentication(options =>
{
    options.ProviderOptions.Authority = "https://cognito-idp.{aws-region}.amazonaws.com/{cognito-userpoolid}";
    options.ProviderOptions.ClientId = "{cognito-clientid}";
    options.ProviderOptions.ResponseType = "code";
    options.ProviderOptions.RedirectUri = "https://localhost:44306/authentication/login-callback";
    options.ProviderOptions.PostLogoutRedirectUri = "https://localhost:44306/authentication/logout-callback";
});

App.razor (从模板创建,无修改)

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p>You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

我自己只修改了Program.cs中对AddOidcAuthentication的调用,在使用个人用户帐户创建 Blazor WebAssembly 应用程序时,Visual Studio 填充了所有其他文件。

我正在努力让它发挥作用,并非常感谢有关此主题的任何帮助

编辑:

根据@aguafrommars 的回答,我已使用 static 网站托管与 Amazon CloudFront 作为 CDN 将网站发布到 Amazon S3,但是,已发布应用程序的行为与描述的本地行为完全相同

扩展问题:

问题1扩展:

当页面显示“正在授权...”时,我只在控制台中收到描述的错误,Cognito 托管的 UI 不会呈现,只有当我点击“登录”时,我才会被重定向(有很大延迟)到 Cognito 托管的 UI ,或者在没有重定向的情况下进行身份验证(如果我之前登录过),也许这个 GIF 会清除一些东西:

blazor/cognito 奇怪的行为

我可能错了,但是Cognito 托管的 UI 拒绝在 iframe 中呈现的问题不是吗? 我的应用程序能否像最终那样重定向到托管的 UI? 现在我必须等待抛出X-Frame-Options错误,点击“登录”,等待另一个X-Frame-Options错误抛出,最后我被重定向并且流程成功(在 gif UI 没有显示,因为我之前在会话中进行了身份验证)

问题2扩展:

我想要实现的行为是,如果用户未经过身份验证,他们将看不到应用程序的任何部分,而是将它们重定向到 Cognito 托管的 UI,并且只有在经过身份验证后才能看到任何内容。 我尝试在MainLayout.razor中使用Authorize属性,但结果始终是空白屏幕,我想提供一些代码和详细信息,但我相信该行为受到问题 1中描述的错误的影响,这就是为什么我会喜欢先整理一下

我最终从 Cognito 切换到 Auth0 并从 Api 网关的 RestApi 升级到 HttpApi,其中包括内置的 JWT 授权器,我对这个变化非常满意。 Cognito 最后只是遇到了太多问题,但如果有人决心让它工作,请检查@aguafrommars 在接受的答案下的评论。

回应1:

在显示授权消息时,应用程序会检查有效身份验证并设置自动更新令牌 iframe。 如果您查看浏览器上的网络日志,您将看到此时发出的请求。
当应用程序在发行版中运行时,它会更快。

回应 2:

您需要通过添加Authorize属性在要保护的页面上添加授权。

@page "/"
@attribute [Authorize]

有同样的问题并切换到 Azure B2C 再次解决了问题。 链接到 AWS Cognito 作为身份验证提供程序时,身份验证库似乎存在问题。

MS 提出的问题 - https://github.com/dotnet/aspnetcore/issues/22651

我正在回答这个在此处标记为重复的问题...

延迟的原因是等待静默登录过程的超时(我相信它有 10 秒的超时),如此处和此处所述

根本原因是 AWS Cognito 不符合 OIDC 标准。 这会导致浏览器控制台中出现“'X-Frame-Options' to 'DENY'”错误。

直到 Blazor 团队允许我们从代码中关闭静默登录,解决方案是禁用静默登录,如下所示:

将位于 asp.net 存储库中的 Blazor 互操作文件下载到本地文件夹。

使用 vs 代码打开本地文件夹并安装 typescript、webpack、yarn 等(如果尚未安装)

npm install -g yarn
npm install -g typescript
npm install -g webpack

然后按如下方式编辑 AuthenticationService.ts 文件(注释掉静默登录功能)。 对不起,长代码打印。

import { UserManager, UserManagerSettings, User } from 'oidc-client'

type Writeable<T> = { -readonly [P in keyof T]: T[P] };

type ExtendedUserManagerSettings = Writeable<UserManagerSettings & AuthorizeServiceSettings>

type OidcAuthorizeServiceSettings = ExtendedUserManagerSettings | ApiAuthorizationSettings;

function isApiAuthorizationSettings(settings: OidcAuthorizeServiceSettings): settings is ApiAuthorizationSettings {
    return settings.hasOwnProperty('configurationEndpoint');
}

interface AuthorizeServiceSettings {
    defaultScopes: string[];
}

interface ApiAuthorizationSettings {
    configurationEndpoint: string;
}

export interface AccessTokenRequestOptions {
    scopes: string[];
    returnUrl: string;
}

export interface AccessTokenResult {
    status: AccessTokenResultStatus;
    token?: AccessToken;
}

export interface AccessToken {
    value: string;
    expires: Date;
    grantedScopes: string[];
}

export enum AccessTokenResultStatus {
    Success = 'success',
    RequiresRedirect = 'requiresRedirect'
}

export enum AuthenticationResultStatus {
    Redirect = 'redirect',
    Success = 'success',
    Failure = 'failure',
    OperationCompleted = 'operationCompleted'
};

export interface AuthenticationResult {
    status: AuthenticationResultStatus;
    state?: unknown;
    message?: string;
}

export interface AuthorizeService {
    getUser(): Promise<unknown>;
    getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
    signIn(state: unknown): Promise<AuthenticationResult>;
    completeSignIn(state: unknown): Promise<AuthenticationResult>;
    signOut(state: unknown): Promise<AuthenticationResult>;
    completeSignOut(url: string): Promise<AuthenticationResult>;
}

class OidcAuthorizeService implements AuthorizeService {
    private _userManager: UserManager;
    private _intialSilentSignIn: Promise<void> | undefined;
    constructor(userManager: UserManager) {
        this._userManager = userManager;
    }

    async trySilentSignIn() {
        if (!this._intialSilentSignIn) {
            this._intialSilentSignIn = (async () => {
                try {
                    await this._userManager.signinSilent();
                } catch (e) {
                    // It is ok to swallow the exception here.
                    // The user might not be logged in and in that case it
                    // is expected for signinSilent to fail and throw
                }
            })();
        }

        return this._intialSilentSignIn;
    }

    async getUser() {
        // if (window.parent === window && !window.opener && !window.frameElement && this._userManager.settings.redirect_uri &&
        //     !location.href.startsWith(this._userManager.settings.redirect_uri)) {
        //     // If we are not inside a hidden iframe, try authenticating silently.
        //     await AuthenticationService.instance.trySilentSignIn();
        // }

        const user = await this._userManager.getUser();
        return user && user.profile;
    }

    async getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult> {
        const user = await this._userManager.getUser();
        if (hasValidAccessToken(user) && hasAllScopes(request, user.scopes)) {
            return {
                status: AccessTokenResultStatus.Success,
                token: {
                    grantedScopes: user.scopes,
                    expires: getExpiration(user.expires_in),
                    value: user.access_token
                }
            };
        } else {
            return {
                status: AccessTokenResultStatus.RequiresRedirect
            };
            // try {
            //     const parameters = request && request.scopes ?
            //         { scope: request.scopes.join(' ') } : undefined;

            //     const newUser = await this._userManager.signinSilent(parameters);

            //     return {
            //         status: AccessTokenResultStatus.Success,
            //         token: {
            //             grantedScopes: newUser.scopes,
            //             expires: getExpiration(newUser.expires_in),
            //             value: newUser.access_token
            //         }
            //     };

            // } catch (e) {
            //     return {
            //         status: AccessTokenResultStatus.RequiresRedirect
            //     };
            // }
        }

        function hasValidAccessToken(user: User | null): user is User {
            return !!(user && user.access_token && !user.expired && user.scopes);
        }

        function getExpiration(expiresIn: number) {
            const now = new Date();
            now.setTime(now.getTime() + expiresIn * 1000);
            return now;
        }

        function hasAllScopes(request: AccessTokenRequestOptions | undefined, currentScopes: string[]) {
            const set = new Set(currentScopes);
            if (request && request.scopes) {
                for (const current of request.scopes) {
                    if (!set.has(current)) {
                        return false;
                    }
                }
            }

            return true;
        }
    }

    async signIn(state: unknown) {
        try {
            await this._userManager.clearStaleState();
            await this._userManager.signinRedirect(this.createArguments(state));
            return this.redirect();
        } catch (redirectError) {
            return this.error(this.getExceptionMessage(redirectError));
        }



        // try {
        //     await this._userManager.clearStaleState();
        //     await this._userManager.signinSilent(this.createArguments());
        //     return this.success(state);
        // } catch (silentError) {
        //     try {
        //         await this._userManager.clearStaleState();
        //         await this._userManager.signinRedirect(this.createArguments(state));
        //         return this.redirect();
        //     } catch (redirectError) {
        //         return this.error(this.getExceptionMessage(redirectError));
        //     }
        // }
    }

    async completeSignIn(url: string) {
        const requiresLogin = await this.loginRequired(url);
        const stateExists = await this.stateExists(url);
        try {
            const user = await this._userManager.signinCallback(url);
            if (window.self !== window.top) {
                return this.operationCompleted();
            } else {
                return this.success(user && user.state);
            }
        } catch (error) {
            if (requiresLogin || window.self !== window.top || !stateExists) {
                return this.operationCompleted();
            }

            return this.error('There was an error signing in.');
        }
    }

    async signOut(state: unknown) {
        try {
            if (!(await this._userManager.metadataService.getEndSessionEndpoint())) {
                await this._userManager.removeUser();
                return this.success(state);
            }
            await this._userManager.signoutRedirect(this.createArguments(state));
            return this.redirect();
        } catch (redirectSignOutError) {
            return this.error(this.getExceptionMessage(redirectSignOutError));
        }
    }

    async completeSignOut(url: string) {
        try {
            if (await this.stateExists(url)) {
                const response = await this._userManager.signoutCallback(url);
                return this.success(response && response.state);
            } else {
                return this.operationCompleted();
            }
        } catch (error) {
            return this.error(this.getExceptionMessage(error));
        }
    }

    private getExceptionMessage(error: any) {
        if (isOidcError(error)) {
            return error.error_description;
        } else if (isRegularError(error)) {
            return error.message;
        } else {
            return error.toString();
        }

        function isOidcError(error: any): error is (Oidc.SigninResponse & Oidc.SignoutResponse) {
            return error && error.error_description;
        }

        function isRegularError(error: any): error is Error {
            return error && error.message;
        }
    }

    private async stateExists(url: string) {
        const stateParam = new URLSearchParams(new URL(url).search).get('state');
        if (stateParam && this._userManager.settings.stateStore) {
            return await this._userManager.settings.stateStore.get(stateParam);
        } else {
            return undefined;
        }
    }

    private async loginRequired(url: string) {
        const errorParameter = new URLSearchParams(new URL(url).search).get('error');
        if (errorParameter && this._userManager.settings.stateStore) {
            const error = await this._userManager.settings.stateStore.get(errorParameter);
            return error === 'login_required';
        } else {
            return false;
        }
    }

    private createArguments(state?: unknown) {
        return { useReplaceToNavigate: true, data: state };
    }

    private error(message: string) {
        return { status: AuthenticationResultStatus.Failure, errorMessage: message };
    }

    private success(state: unknown) {
        return { status: AuthenticationResultStatus.Success, state };
    }

    private redirect() {
        return { status: AuthenticationResultStatus.Redirect };
    }

    private operationCompleted() {
        return { status: AuthenticationResultStatus.OperationCompleted };
    }
}

export class AuthenticationService {

    static _infrastructureKey = 'Microsoft.AspNetCore.Components.WebAssembly.Authentication';
    static _initialized: Promise<void>;
    static instance: OidcAuthorizeService;
    static _pendingOperations: { [key: string]: Promise<AuthenticationResult> | undefined } = {}

    public static init(settings: UserManagerSettings & AuthorizeServiceSettings) {
        // Multiple initializations can start concurrently and we want to avoid that.
        // In order to do so, we create an initialization promise and the first call to init
        // tries to initialize the app and sets up a promise other calls can await on.
        if (!AuthenticationService._initialized) {
            AuthenticationService._initialized = AuthenticationService.initializeCore(settings);
        }

        return AuthenticationService._initialized;
    }

    public static handleCallback() {
        return AuthenticationService.initializeCore();
    }

    private static async initializeCore(settings?: UserManagerSettings & AuthorizeServiceSettings) {
        const finalSettings = settings || AuthenticationService.resolveCachedSettings();
        if (!settings && finalSettings) {
            const userManager = AuthenticationService.createUserManagerCore(finalSettings);

            if (window.parent !== window && !window.opener && (window.frameElement && userManager.settings.redirect_uri &&
                location.href.startsWith(userManager.settings.redirect_uri))) {
                // If we are inside a hidden iframe, try completing the sign in early.
                // This prevents loading the blazor app inside a hidden iframe, which speeds up the authentication operations
                // and avoids wasting resources (CPU and memory from bootstrapping the Blazor app)
                AuthenticationService.instance = new OidcAuthorizeService(userManager);

                // This makes sure that if the blazor app has time to load inside the hidden iframe,
                // it is not able to perform another auth operation until this operation has completed.
                AuthenticationService._initialized = (async (): Promise<void> => {
                    await AuthenticationService.instance.completeSignIn(location.href);
                    return;
                })();
            }
        } else if (settings) {
            const userManager = await AuthenticationService.createUserManager(settings);
            AuthenticationService.instance = new OidcAuthorizeService(userManager);
        } else {
            // HandleCallback gets called unconditionally, so we do nothing for normal paths.
            // Cached settings are only used on handling the redirect_uri path and if the settings are not there
            // the app will fallback to the default logic for handling the redirect.
        }
    }

    private static resolveCachedSettings(): UserManagerSettings | undefined {
        const cachedSettings = window.sessionStorage.getItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`);
        return cachedSettings ? JSON.parse(cachedSettings) : undefined;
    }

    public static getUser() {
        return AuthenticationService.instance.getUser();
    }

    public static getAccessToken(options: AccessTokenRequestOptions) {
        return AuthenticationService.instance.getAccessToken(options);
    }

    public static signIn(state: unknown) {
        return AuthenticationService.instance.signIn(state);
    }

    public static async completeSignIn(url: string) {
        let operation = this._pendingOperations[url];
        if (!operation) {
            operation = AuthenticationService.instance.completeSignIn(url);
            await operation;
            delete this._pendingOperations[url];
        }

        return operation;
    }

    public static signOut(state: unknown) {
        return AuthenticationService.instance.signOut(state);
    }

    public static async completeSignOut(url: string) {
        let operation = this._pendingOperations[url];
        if (!operation) {
            operation = AuthenticationService.instance.completeSignOut(url);
            await operation;
            delete this._pendingOperations[url];
        }

        return operation;
    }

    private static async createUserManager(settings: OidcAuthorizeServiceSettings): Promise<UserManager> {
        let finalSettings: UserManagerSettings;
        if (isApiAuthorizationSettings(settings)) {
            const response = await fetch(settings.configurationEndpoint);
            if (!response.ok) {
                throw new Error(`Could not load settings from '${settings.configurationEndpoint}'`);
            }

            const downloadedSettings = await response.json();

            finalSettings = downloadedSettings;
        } else {
            if (!settings.scope) {
                settings.scope = settings.defaultScopes.join(' ');
            }

            if (settings.response_type === null) {
                // If the response type is not set, it gets serialized as null. OIDC-client behaves differently than when the value is undefined, so we explicitly check for a null value and remove the property instead.
                delete settings.response_type;
            }

            finalSettings = settings;
        }

        window.sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`, JSON.stringify(finalSettings));

        return AuthenticationService.createUserManagerCore(finalSettings);
    }

    private static createUserManagerCore(finalSettings: UserManagerSettings) {
        const userManager = new UserManager(finalSettings);
        userManager.events.addUserSignedOut(async () => {
            userManager.removeUser();
        });
        return userManager;
    }
}

declare global {
    interface Window { AuthenticationService: AuthenticationService }
}

AuthenticationService.handleCallback();

window.AuthenticationService = AuthenticationService;

然后构建js

yarn build:release

编译 js 文件后,将AuthenticationService.js文件复制到 Blazor WASM 应用程序的/wwwroot目录中。

然后在 index.html 文件中,注释掉 MS 脚本并替换为自己的:

<!--    <script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>-->
<script src="AuthenticationService.js"></script>

运行您的应用程序,Cognito 现在将(相对)即时

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM