简体   繁体   English

Blazor WebAssembly + Amazon Cognito

[英]Blazor WebAssembly + Amazon Cognito

I would like to set up a Blazor client-side app with authentication through AWS Cognito .我想设置一个Blazor客户端应用程序,通过AWS Cognito进行身份验证。

When I run the app I'm not redirected to a login page, instead the page says "Authorizing..." for a few seconds, while I get this error in the console:当我运行应用程序时,我没有重定向到登录页面,而是页面显示“正在授权...”几秒钟,而我在控制台中收到此错误:

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.

Then, the default "Hello, world!"然后,默认的“你好,世界!” index page is shown (even though as I understand it, it should not be visible to an unauthenticated user based on App.razor definition?) .显示索引页面(尽管据我了解,根据 App.razor 定义,未经身份验证的用户不应该看到它?) If I click on "Log in", I get the same error in console, but then after a few seconds the Cognito-hosted login page opens, I am able to log in, I am redirected back to my app, and the app shows the authenticated user's info in top right corner, but the console is a little weird again:如果我单击“登录”,我会在控制台中收到相同的错误,但几秒钟后,Cognito 托管的登录页面打开,我可以登录,我被重定向回我的应用程序,并且应用程序显示经过身份验证的用户信息在右上角,但控制台又有点奇怪:

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

Question 1问题 1

How can I get rid of these errors and have my app redirect to Cognito login page without ~10s delay?我怎样才能摆脱这些错误并让我的应用程序重定向到 Cognito 登录页面而不会有约 10 秒的延迟?

Question 2问题2

Why is all content in my app visible at all times regardless of whether I'm authenticated or not?为什么无论我是否经过身份验证,我的应用程序中的所有内容始终可见? It's as if the NotAuthorized node under AuthorizeRouteView in App.razor had no effect at all, unless I am confusing something here就好像 App.razor 中AuthorizeRouteView下的NotAuthorized节点App.razor没有效果,除非我在这里混淆了一些东西

Code:代码:

Program.cs程序.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 (as created from template, no modifications) 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>

I have only modified the call to AddOidcAuthentication in Program.cs myself, all other files were populated by Visual Studio when creating a Blazor WebAssembly App with Individual User Accounts.我自己只修改了Program.cs中对AddOidcAuthentication的调用,在使用个人用户帐户创建 Blazor WebAssembly 应用程序时,Visual Studio 填充了所有其他文件。

I am struggling to get this to work and would greatly appreciate any help on this topic我正在努力让它发挥作用,并非常感谢有关此主题的任何帮助

EDIT:编辑:

Following @aguafrommars's answer I have published the website to Amazon S3 using static website hosting with Amazon CloudFront as CDN, however, the behavior of the published app is exactly the same as described local behavior根据@aguafrommars 的回答,我已使用 static 网站托管与 Amazon CloudFront 作为 CDN 将网站发布到 Amazon S3,但是,已发布应用程序的行为与描述的本地行为完全相同

To expand on the questions:扩展问题:

Question 1 expanded:问题1扩展:

When the page says "Authorizing..." I only get the described error in the console, the Cognito hosted UI is not rendered, only when I click on "Log in" I am either redirected (with major delay) to Cognito hosted UI, or authenticated without redirection (if I signed in before), perhaps this GIF will clear things up:当页面显示“正在授权...”时,我只在控制台中收到描述的错误,Cognito 托管的 UI 不会呈现,只有当我点击“登录”时,我才会被重定向(有很大延迟)到 Cognito 托管的 UI ,或者在没有重定向的情况下进行身份验证(如果我之前登录过),也许这个 GIF 会清除一些东西:

blazor/cognito 奇怪的行为

I might be wrong, but isn't the problem that the Cognito hosted UI is rejecting to be rendered in an iframe ?我可能错了,但是Cognito 托管的 UI 拒绝在 iframe 中呈现的问题不是吗? Can my app redirect to the hosted UI in the first place, like it eventually does?我的应用程序能否像最终那样重定向到托管的 UI? Right now I have to wait while X-Frame-Options error is thrown, click on "Log in", wait while another X-Frame-Options error is thrown, and then finally I'm redirected and the flow succeeds (in the gif the UI doesn't show up because I authenticated before in the session)现在我必须等待抛出X-Frame-Options错误,点击“登录”,等待另一个X-Frame-Options错误抛出,最后我被重定向并且流程成功(在 gif UI 没有显示,因为我之前在会话中进行了身份验证)

Question 2 expanded:问题2扩展:

The behavior I want to achieve is that if the user is not authenticated, they cannot see any part of the application, instead they are redirected to Cognito hosted UI and only after they are authenticated they can see anything.我想要实现的行为是,如果用户未经过身份验证,他们将看不到应用程序的任何部分,而是将它们重定向到 Cognito 托管的 UI,并且只有在经过身份验证后才能看到任何内容。 I tried to play around with Authorize attribute in MainLayout.razor , but the result is always a blank screen, I would like to provide some code and details but I believe the behavior is impacted by errors described in Question 1 , which is why I would like to sort it out first我尝试在MainLayout.razor中使用Authorize属性,但结果始终是空白屏幕,我想提供一些代码和详细信息,但我相信该行为受到问题 1中描述的错误的影响,这就是为什么我会喜欢先整理一下

I ended up switching from Cognito to Auth0 and upgrading from Api Gateway's RestApi to HttpApi, which includes built in JWT authorizer, and I am very happy with the change.我最终从 Cognito 切换到 Auth0 并从 Api 网关的 RestApi 升级到 HttpApi,其中包括内置的 JWT 授权器,我对这个变化非常满意。 Cognito just had too many problems in the end, but if someone is determined to get it to work check @aguafrommars's comments under the accepted answer. Cognito 最后只是遇到了太多问题,但如果有人决心让它工作,请检查@aguafrommars 在接受的答案下的评论。

Response 1:回应1:

While the authorizing message is displayed, the app check for a valid authentication and set up auto renew token iframe.在显示授权消息时,应用程序会检查有效身份验证并设置自动更新令牌 iframe。 If you look at the network log on your browser you'll see requests made by this time.如果您查看浏览器上的网络日志,您将看到此时发出的请求。
When the app run in release it's faster.当应用程序在发行版中运行时,它会更快。

Response 2:回应 2:

You need to add authorization on pages you want to protect by adding the Authorize attribute .您需要通过添加Authorize属性在要保护的页面上添加授权。

@page "/"
@attribute [Authorize]

Had the same problem and switched out to Azure B2C which again resolved the issue.有同样的问题并切换到 Azure B2C 再次解决了问题。 Appears to be a problem with the auth library when linking to AWS Cognito as the auth provider.链接到 AWS Cognito 作为身份验证提供程序时,身份验证库似乎存在问题。

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

I'm answering this issue which was marked as duplicate here...我正在回答这个在此处标记为重复的问题...

The reason for the delay is the timeout waiting for the silent login process (which has a 10s timeout I believe) as mentioned here and here延迟的原因是等待静默登录过程的超时(我相信它有 10 秒的超时),如此处和此处所述

Root cause is that AWS Cognito is not compliant with the OIDC standards.根本原因是 AWS Cognito 不符合 OIDC 标准。 Which results in the "'X-Frame-Options' to 'DENY'" error in the browser console.这会导致浏览器控制台中出现“'X-Frame-Options' to 'DENY'”错误。

Until the Blazor team allow us to turn off the silent signin from code, the solution was to disable silent login as follows:直到 Blazor 团队允许我们从代码中关闭静默登录,解决方案是禁用静默登录,如下所示:

Download the Blazor Interop files located in the asp.net repo here to a local folder.将位于 asp.net 存储库中的 Blazor 互操作文件下载到本地文件夹。

Open the local folder using vs code and install typescript, webpack, yarn, etc if not already installed使用 vs 代码打开本地文件夹并安装 typescript、webpack、yarn 等(如果尚未安装)

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

Then edit the AuthenticationService.ts file as follows (commenting out the silent signin functionality).然后按如下方式编辑 AuthenticationService.ts 文件(注释掉静默登录功能)。 Sorry for the long code print.对不起,长代码打印。

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;

Then build the js with然后构建js

yarn build:release

Once the js file is compiled, copy the AuthenticationService.js file into the /wwwroot directory of your Blazor WASM app.编译 js 文件后,将AuthenticationService.js文件复制到 Blazor WASM 应用程序的/wwwroot目录中。

Then in the index.html file, comment out the MS script and replace with your own:然后在 index.html 文件中,注释掉 MS 脚本并替换为自己的:

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

Run your app and Cognito will now be (relatively) instantaneous运行您的应用程序,Cognito 现在将(相对)即时

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

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