Blazor OIDC 單點登入授權例項7 - Blazor hybird app 端授權

AlexChow發表於2024-04-10

目錄:

  1. OpenID 與 OAuth2 基礎知識
  2. Blazor wasm Google 登入
  3. Blazor wasm Gitee 碼雲登入
  4. Blazor OIDC 單點登入授權例項1-建立和配置IDS身份驗證服務
  5. Blazor OIDC 單點登入授權例項2-登入資訊元件wasm
  6. Blazor OIDC 單點登入授權例項3-服務端管理元件
  7. Blazor OIDC 單點登入授權例項4 - 部署服務端/獨立WASM端授權
  8. Blazor OIDC 單點登入授權例項5 - 獨立SSR App (net8 webapp)端授權
  9. Blazor OIDC 單點登入授權例項6 - Winform 端授權
  10. Blazor OIDC 單點登入授權例項7 - Blazor hybird app 端授權

(目錄暫時不更新,跟隨合集標題往下走)

原始碼

BlazorOIDC.WinForms

建立 BlazorOIDC.WinForms 工程

自行安裝 Vijay Anand E G 模板,快速建立 Blazor WinForms 工程, 命名為 BlazorOIDC.WinForms

引用以下庫

    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.4" />
        <PackageReference Include="Microsoft.AspNetCore.Components.WebView.WindowsForms" Version="8.*" />
        <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
        <FrameworkReference Include="Microsoft.AspNetCore.App"></FrameworkReference>
        <PackageReference Include="IdentityModel.OidcClient" Version="5.2.1" />
    </ItemGroup>

_Imports.razor 加入引用

@using Microsoft.AspNetCore.Components.Authorization

Main.razor 加入授權

完整程式碼

<CascadingAuthenticationState>
    <Router AppAssembly="@GetType().Assembly">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

新增Oidc授權配置

新建檔案 ExternalAuthStateProvider.cs

完整程式碼

using IdentityModel.OidcClient;
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;

namespace BlazorOIDC.WinForms;

public class ExternalAuthStateProvider : AuthenticationStateProvider
{
    private readonly Task<AuthenticationState> authenticationState;

    public ExternalAuthStateProvider(AuthenticatedUser user) =>
        authenticationState = Task.FromResult(new AuthenticationState(user.Principal));

    private ClaimsPrincipal currentUser = new ClaimsPrincipal(new ClaimsIdentity());

    public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
        Task.FromResult(new AuthenticationState(currentUser));

    public Task<AuthenticationState> LogInAsync()
    {
        var loginTask = LogInAsyncCore();
        NotifyAuthenticationStateChanged(loginTask);

        return loginTask;

        async Task<AuthenticationState> LogInAsyncCore()
        {
            var user = await LoginWithExternalProviderAsync();
            currentUser = user;

            return new AuthenticationState(currentUser);
        }
    }

    private async Task<ClaimsPrincipal> LoginWithExternalProviderAsync()
    {
        /*
            提供 Open ID/MSAL 程式碼以對使用者進行身份驗證。檢視您的身份
            提供商的文件以獲取詳細資訊。

            根據新的宣告身份返回新的宣告主體。
        */

        string authority = "https://localhost:5001/";
        //string authority = "https://ids2.app1.es/"; //真實環境
        string api = $"{authority}WeatherForecast";
        string clientId = "Blazor5002";

        OidcClient? _oidcClient;
        HttpClient _apiClient = new HttpClient { BaseAddress = new Uri(api) };

        var browser = new SystemBrowser(5002);
        var redirectUri = string.Format($"http://localhost:{browser.Port}/authentication/login-callback");
        var redirectLogoutUri = string.Format($"http://localhost:{browser.Port}/authentication/logout-callback");

        var options = new OidcClientOptions
        {
            Authority = authority,
            ClientId = clientId,
            RedirectUri = redirectUri,
            PostLogoutRedirectUri = redirectLogoutUri,
            Scope = "BlazorWasmIdentity.ServerAPI openid profile",
            //Scope = "Blazor7.ServerAPI openid profile",
            Browser = browser,
            Policy = new Policy { RequireIdentityTokenSignature = false }

        };

        _oidcClient = new OidcClient(options);
        var result = await _oidcClient.LoginAsync(new LoginRequest());
        ShowResult(result);

        var authenticatedUser = result.User;

        return authenticatedUser;
    }

    private static void ShowResult(LoginResult result, bool showToken = false)
    {
        if (result.IsError)
        {
            Console.WriteLine("\n\nError:\n{0}", result.Error);
            return;
        }

        Console.WriteLine("\n\nClaims:");
        foreach (var claim in result.User.Claims)
        {
            Console.WriteLine("{0}: {1}", claim.Type, claim.Value);
        }

        if (showToken)
        {
            Console.WriteLine($"\nidentity token: {result.IdentityToken}");
            Console.WriteLine($"access token:   {result.AccessToken}");
            Console.WriteLine($"refresh token:  {result?.RefreshToken ?? "none"}");
        }
    }

    public Task Logout()
    {
        currentUser = new ClaimsPrincipal(new ClaimsIdentity());
        NotifyAuthenticationStateChanged(
            Task.FromResult(new AuthenticationState(currentUser)));
        return Task.CompletedTask;
    }
}

public class AuthenticatedUser
{
    public ClaimsPrincipal Principal { get; set; } = new();
}

新增Oidc瀏覽器授權方法

新建檔案 SystemBrowser.cs

完整程式碼

using IdentityModel.OidcClient.Browser;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
#nullable disable

namespace BlazorOIDC.WinForms;

public class SystemBrowser : IBrowser
{
    public int Port { get; }
    private readonly string _path;

    public SystemBrowser(int? port = null, string path = null)
    {
        _path = path;

        if (!port.HasValue)
        {
            Port = GetRandomUnusedPort();
        }
        else
        {
            Port = port.Value;
        }
    }

    private int GetRandomUnusedPort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
    {
        using (var listener = new LoopbackHttpListener(Port, _path))
        {
            OpenBrowser(options.StartUrl);

            try
            {
                var result = await listener.WaitForCallbackAsync();
                if (string.IsNullOrWhiteSpace(result))
                {
                    return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = "Empty response." };
                }

                return new BrowserResult { Response = result, ResultType = BrowserResultType.Success };
            }
            catch (TaskCanceledException ex)
            {
                return new BrowserResult { ResultType = BrowserResultType.Timeout, Error = ex.Message };
            }
            catch (Exception ex)
            {
                return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message };
            }
        }
    }

    public static void OpenBrowser(string url)
    {
        try
        {
            Process.Start(url);
        }
        catch
        {
            // hack because of this: https://github.com/dotnet/corefx/issues/10361
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                url = url.Replace("&", "^&");
                Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true });
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
            {
                Process.Start("xdg-open", url);
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
            {
                Process.Start("open", url);
            }
            else
            {
                throw;
            }
        }
    }
}

public class LoopbackHttpListener : IDisposable
{
    const int DefaultTimeout = 60 * 5; // 5 mins (in seconds)

    IWebHost _host;
    TaskCompletionSource<string> _source = new TaskCompletionSource<string>();

    public string Url { get; }

    public LoopbackHttpListener(int port, string path = null)
    {
        path = path ?? string.Empty;
        if (path.StartsWith("/")) path = path.Substring(1);

        Url = $"http://localhost:{port}/{path}";

        _host = new WebHostBuilder()
            .UseKestrel()
            .UseUrls(Url)
            .Configure(Configure)
            .Build();
        _host.Start();
    }

    public void Dispose()
    {
        Task.Run(async () =>
        {
            await Task.Delay(500);
            _host.Dispose();
        });
    }

    void Configure(IApplicationBuilder app)
    {
        app.Run(async ctx =>
        {
            if (ctx.Request.Method == "GET")
            {
                await SetResultAsync(ctx.Request.QueryString.Value, ctx);
            }
            else if (ctx.Request.Method == "POST")
            {
                if (!ctx.Request.ContentType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
                {
                    ctx.Response.StatusCode = 415;
                }
                else
                {
                    using (var sr = new StreamReader(ctx.Request.Body, Encoding.UTF8))
                    {
                        var body = await sr.ReadToEndAsync();
                        await SetResultAsync(body, ctx);
                    }
                }
            }
            else
            {
                ctx.Response.StatusCode = 405;
            }
        });
    }

    private async Task SetResultAsync(string value, HttpContext ctx)
    {
        try
        {
            ctx.Response.StatusCode = 200;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync("<h1>您現在可以返回應用程式.</h1>");
            await ctx.Response.Body.FlushAsync();

            _source.TrySetResult(value);
        }
        catch(Exception ex)
        {
            Console.WriteLine(ex.ToString());

            ctx.Response.StatusCode = 400;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync("<h1>無效的請求.</h1>");
            await ctx.Response.Body.FlushAsync();
        }
    }

    public Task<string> WaitForCallbackAsync(int timeoutInSeconds = DefaultTimeout)
    {
        Task.Run(async () =>
        {
            await Task.Delay(timeoutInSeconds * 1000);
            _source.TrySetCanceled();
        });

        return _source.Task;
    }
}

Shared 資料夾新建登入/登出頁面元件

LoginComponent.razor

完整程式碼

@inject AuthenticationStateProvider AuthenticationStateProvider
@page "/Login"
@using System.Security.Claims

<button @onclick="Login">Log in</button>

<p>@Msg</p>
 

<AuthorizeView>
    <Authorized>

        你好, @context.User.Identity?.Name
 
        <br /><br /><br />
        <h5>以下是使用者的宣告</h5><br />

        @foreach (var claim in context.User.Claims)
        {
            <p>@claim.Type: @claim.Value</p>
        } 
 

    </Authorized> 

</AuthorizeView>


<p>以下是基於角色或基於策略的授權,未登入不顯示 </p>

<AuthorizeView Roles="Admin, Superuser">
    <p>只有管理員或超級使用者才能看到.</p>
</AuthorizeView>

@code
{
    [Inject]
    private AuthenticatedUser? authenticatedUser { get; set; }

    /// <summary>
    /// 級聯引數獲取身份驗證狀態資料
    /// </summary>
    [CascadingParameter]
    private Task<AuthenticationState>? authenticationStateTask { get; set; }

    private string? Msg { get; set; }

    private ClaimsPrincipal? User { get; set; }

    public async Task Login()
    {
        var authenticationState = await ((ExternalAuthStateProvider)AuthenticationStateProvider).LogInAsync();

        User = authenticationState?.User;

        if (User != null)
        {
            if (User.Identity != null && User.Identity.IsAuthenticated)
            {
                Msg += "已登入." + Environment.NewLine;
            }
        }
    }
}

LogoutComponent.razor

完整程式碼

@inject AuthenticationStateProvider AuthenticationStateProvider
@page "/Logout"

<button @onclick="Logout">Log out</button>

@code
{
    public async Task Logout()
    {
        await ((ExternalAuthStateProvider)AuthenticationStateProvider).Logout();
    }
}
		<div class="nav-item px-3">
            <NavLink class="nav-link" href="Login">
                <span class="oi oi-plus" aria-hidden="true"></span> Login
            </NavLink>
		</div>
		<div class="nav-item px-3">
            <NavLink class="nav-link" href="Logout">
                <span class="oi oi-plus" aria-hidden="true"></span> Logout
            </NavLink>
		</div>

Form1.cs 修改首頁


        var blazor = new BlazorWebView()
        {
            Dock = DockStyle.Fill,
            HostPage = "wwwroot/index.html",
            Services = Startup.Services!,
            StartPath = "/Login"
        };
        blazor.RootComponents.Add<Main>("#app");
        Controls.Add(blazor);

Startup.cs 註冊服務

完整程式碼

using BlazorOIDC.WinForms.Data;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;

namespace BlazorOIDC.WinForms;
public static class Startup
{
    public static IServiceProvider? Services { get; private set; }

    public static void Init()
    {
        var host = Host.CreateDefaultBuilder()
                       .ConfigureServices(WireupServices)
                       .Build();
        Services = host.Services;
    }

    private static void WireupServices(IServiceCollection services)
    {
        services.AddWindowsFormsBlazorWebView();
        services.AddSingleton<WeatherForecastService>();

        services.AddAuthorizationCore();
        services.TryAddScoped<AuthenticationStateProvider, ExternalAuthStateProvider>();
        services.AddSingleton<AuthenticatedUser>();
 
  

#if DEBUG
        services.AddBlazorWebViewDeveloperTools();
#endif
    }
}

執行

相關文章