From d7fa9e37ccdb5559f7d1f28e51867bacff389ad2 Mon Sep 17 00:00:00 2001 From: Zhi Yuan Date: Wed, 13 Apr 2022 22:25:22 +0800 Subject: [PATCH] Authn (#125) --- .github/workflows/azure-api-function.yml | 47 ++++++++++++ .github/workflows/azure-client-storage.yml | 48 +++++++++++++ .../workflows/azure-messaging-function.yml | 9 +-- ...tatic-web-apps-orange-island-0901fe000.yml | 45 ------------ Api/Couple.Api.csproj | 1 + Api/Features/Utility/Warmer.cs | 14 ---- Api/Infrastructure/CurrentUserService.cs | 72 ++----------------- Client/App.razor | 27 +++---- Client/Couple.Client.csproj | 16 +++-- .../ApiAuthorizationMessageHandler.cs | 18 +++++ Client/Pages/Authentication.razor | 9 +++ Client/Program.cs | 41 +++++------ Client/Shared/MainLayout.razor.cs | 21 +++++- Client/Utility/Constants.cs | 1 + Client/_Imports.razor | 3 + Client/wwwroot/appsettings.Development.json | 7 +- Client/wwwroot/appsettings.json | 8 +++ Client/wwwroot/index.html | 1 + Client/wwwroot/staticwebapp.config.json | 72 ------------------- docs/developer_guide.md | 2 +- 20 files changed, 221 insertions(+), 241 deletions(-) create mode 100644 .github/workflows/azure-api-function.yml create mode 100644 .github/workflows/azure-client-storage.yml delete mode 100644 .github/workflows/azure-static-web-apps-orange-island-0901fe000.yml delete mode 100644 Api/Features/Utility/Warmer.cs create mode 100644 Client/Infrastructure/ApiAuthorizationMessageHandler.cs create mode 100644 Client/Pages/Authentication.razor create mode 100644 Client/wwwroot/appsettings.json delete mode 100644 Client/wwwroot/staticwebapp.config.json diff --git a/.github/workflows/azure-api-function.yml b/.github/workflows/azure-api-function.yml new file mode 100644 index 00000000..2c05f3c9 --- /dev/null +++ b/.github/workflows/azure-api-function.yml @@ -0,0 +1,47 @@ +# https://docs.microsoft.com/en-us/azure/azure-functions/functions-how-to-github-actions?tabs=dotnet#deploy-the-function-app + +name: Deploy Api to Function App + +on: + [push] + +env: + AZURE_FUNCTIONAPP_NAME: couple-api + AZURE_FUNCTIONAPP_PROJ_PATH: 'Api' + DOTNET_VERSION: '6.0.x' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: 'Checkout' + uses: actions/checkout@v2 + + - name: Setup DotNet ${{ env.DOTNET_VERSION }} Environment + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: 'Build' + shell: bash + run: | + pushd './${{ env.AZURE_FUNCTIONAPP_PROJ_PATH }}' + dotnet build --configuration Release --output ./output + popd + + - name: 'Install Azure Functions Core Tools' + run: npm i -g azure-functions-core-tools@4 --unsafe-perm true + + - name: 'Login to Azure' + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: 'Publish' + run: func azure functionapp publish ${{ env.AZURE_FUNCTIONAPP_NAME }} + working-directory: ${{ env.AZURE_FUNCTIONAPP_PROJ_PATH }} + + - name: 'Logout of Azure' + run: | + az logout + if: always() diff --git a/.github/workflows/azure-client-storage.yml b/.github/workflows/azure-client-storage.yml new file mode 100644 index 00000000..ddf71918 --- /dev/null +++ b/.github/workflows/azure-client-storage.yml @@ -0,0 +1,48 @@ +# https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blobs-static-site-github-actions + +name: Deploy Client to Azure Storage + +on: + [push] + +env: + AZURE_CLIENT_PATH: 'Client' + DOTNET_VERSION: '6.0.x' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: 'Checkout' + uses: actions/checkout@v2 + + - name: Setup DotNet ${{ env.DOTNET_VERSION }} Environment + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: 'Build' + shell: bash + run: | + pushd './${{ env.AZURE_CLIENT_PATH }}' + dotnet workload install wasm-tools + dotnet publish --configuration Release --output ./output + mv ./output/wwwroot/* ./output/ + popd + + - name: 'Login to Azure' + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: 'Deploy' + uses: azure/CLI@v1 + with: + inlineScript: | + az storage blob delete-batch --account-name couple --auth-mode key --source '$web' + az storage blob upload-batch --account-name couple --auth-mode key --destination '$web' --source ./Client/output + + - name: 'Logout of Azure' + run: | + az logout + if: always() diff --git a/.github/workflows/azure-messaging-function.yml b/.github/workflows/azure-messaging-function.yml index 33ec4088..a858faac 100644 --- a/.github/workflows/azure-messaging-function.yml +++ b/.github/workflows/azure-messaging-function.yml @@ -1,6 +1,6 @@ # https://docs.microsoft.com/en-us/azure/azure-functions/functions-how-to-github-actions?tabs=dotnet#deploy-the-function-app -name: Deploy DotNet project to function app with a Linux environment +name: Deploy Messaging to Function App on: [push] @@ -14,7 +14,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - name: 'Checkout GitHub Action' + - name: 'Checkout' uses: actions/checkout@v2 - name: Setup DotNet ${{ env.DOTNET_VERSION }} Environment @@ -22,13 +22,14 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: 'Resolve Project Dependencies Using Dotnet' + - name: 'Build' shell: bash run: | pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' dotnet build --configuration Release --output ./output popd - - name: 'Run Azure Functions Action' + + - name: 'Deploy' uses: Azure/functions-action@v1 id: fa with: diff --git a/.github/workflows/azure-static-web-apps-orange-island-0901fe000.yml b/.github/workflows/azure-static-web-apps-orange-island-0901fe000.yml deleted file mode 100644 index 547bc7ce..00000000 --- a/.github/workflows/azure-static-web-apps-orange-island-0901fe000.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Azure Static Web Apps CI/CD - -on: - push: - branches: - - master - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - master - -jobs: - build_and_deploy_job: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job - steps: - - uses: actions/checkout@v2 - with: - submodules: true - - name: Build And Deploy - id: builddeploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ORANGE_ISLAND_0901FE000 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) - action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "Client" # App source code path - api_location: "Api" # Api source code path - optional - output_location: "wwwroot" # Built app content directory - optional - ###### End of Repository/Build Configurations ###### - - close_pull_request_job: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - name: Close Pull Request Job - steps: - - name: Close Pull Request - id: closepullrequest - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ORANGE_ISLAND_0901FE000 }} - action: "close" diff --git a/Api/Couple.Api.csproj b/Api/Couple.Api.csproj index b87a7d90..cc466847 100644 --- a/Api/Couple.Api.csproj +++ b/Api/Couple.Api.csproj @@ -18,6 +18,7 @@ + diff --git a/Api/Features/Utility/Warmer.cs b/Api/Features/Utility/Warmer.cs deleted file mode 100644 index 8a1849d9..00000000 --- a/Api/Features/Utility/Warmer.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Net; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; - -namespace Couple.Api.Features.Utility; - -public class Warmer -{ - [Function("Warmer")] - public HttpResponseData Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "get")] - HttpRequestData req) => - req.CreateResponse(HttpStatusCode.OK); -} diff --git a/Api/Infrastructure/CurrentUserService.cs b/Api/Infrastructure/CurrentUserService.cs index 79c0db6c..fb2fb944 100644 --- a/Api/Infrastructure/CurrentUserService.cs +++ b/Api/Infrastructure/CurrentUserService.cs @@ -1,77 +1,15 @@ +using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text; -using System.Text.Json; namespace Couple.Api.Infrastructure; public class CurrentUserService : ICurrentUserService { - private const string ClaimTypePartnerId = "PartnerId"; - public Claims GetClaims(HttpHeaders headers) { - var clientPrincipal = StaticWebAppsAuth.Parse(headers); - - var id = clientPrincipal.FindFirstValue(ClaimTypes.NameIdentifier)!; - var partnerId = clientPrincipal.FindFirstValue(ClaimTypePartnerId)!; - - return new(id, partnerId); - } - - // from https://docs.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp#api-functions - private static class StaticWebAppsAuth - { - public static ClaimsPrincipal Parse(HttpHeaders headers) - { - var data = headers.GetValues("x-ms-client-principal").First(); - var decoded = Convert.FromBase64String(data); - var json = Encoding.ASCII.GetString(decoded); - var principal = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; - -#pragma warning disable CS8604 - var roles = principal.UserRoles -#pragma warning restore CS8604 - .Where(role => role != "anonymous" - && role != "authenticated" - && !role.StartsWith("id_") - && !role.StartsWith("partnerid_")) - .Select(r => new Claim(ClaimTypes.Role, r)) - .ToList(); - - if (!roles.Any()) - { - return new(); - } - - var adminAssignedId = principal.UserRoles - .Single(role => role.StartsWith("id_")); - var partnerId = principal.UserRoles - .Single(role => role.StartsWith("partnerid_")); - - var identity = new ClaimsIdentity(principal.IdentityProvider); - - // Azure Static Web App does not allow us to add custom properties. Therefore, - // PartnerId needs to be stored as a role instead. However, the default Id generated by Azure SWA - // is longer than 25 characters, and Azure SWA disallows roles from having more than 25 characters. - // Therefore, this is the temporary workaround in order to store partnerId. - // By right we should be using principal.UserId, which is pending AAD B2C implementation: - // See https://github.com/Azure/static-web-apps/issues/3 - // An alternative implementation is to return a custom Cookie / JWT, but that requires more effort - // with little gains, given the current state of the project. - identity.AddClaim(new(ClaimTypes.NameIdentifier, adminAssignedId[3..])); - identity.AddClaim(new(ClaimTypePartnerId, partnerId[10..])); - identity.AddClaims(roles); - return new(identity); - } - - private class ClientPrincipal - { - public string? IdentityProvider { get; set; } - public string? UserId { get; set; } - public string? UserDetails { get; set; } - public IEnumerable? UserRoles { get; set; } - } + var authValues = headers.GetValues("authorization"); + var authHeader = AuthenticationHeaderValue.Parse(authValues.ToArray().First()); + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(authHeader.Parameter); + return new(jwt.Subject, jwt.Claims.First(c => c.Type == "name").Value); } } diff --git a/Client/App.razor b/Client/App.razor index 3bca97be..c6a2f542 100644 --- a/Client/App.razor +++ b/Client/App.razor @@ -1,14 +1,17 @@ @inject NavigationManager _navigationManager - - - - - - - @{ - _navigationManager.NavigateTo(""); - } - - - + + + + + + + + + @{ + _navigationManager.NavigateTo(""); + } + + + + diff --git a/Client/Couple.Client.csproj b/Client/Couple.Client.csproj index 0b0a3541..b88b9226 100644 --- a/Client/Couple.Client.csproj +++ b/Client/Couple.Client.csproj @@ -15,11 +15,14 @@ - - - + + + + + + - + @@ -30,4 +33,9 @@ + + + + + diff --git a/Client/Infrastructure/ApiAuthorizationMessageHandler.cs b/Client/Infrastructure/ApiAuthorizationMessageHandler.cs new file mode 100644 index 00000000..bb52de54 --- /dev/null +++ b/Client/Infrastructure/ApiAuthorizationMessageHandler.cs @@ -0,0 +1,18 @@ +using Couple.Client.Utility; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; + +namespace Couple.Client.Infrastructure; + +public class ApiAuthorizationMessageHandler : AuthorizationMessageHandler +{ + public const string Scope = @"https://couplesg.onmicrosoft.com/api/all"; + + public ApiAuthorizationMessageHandler(IAccessTokenProvider provider, + NavigationManager navigationManager, + IConfiguration configuration) + : base(provider, navigationManager) => + ConfigureHandler( + new[] { configuration[Constants.ApiPrefix]! }, + new[] { Scope }); +} diff --git a/Client/Pages/Authentication.razor b/Client/Pages/Authentication.razor new file mode 100644 index 00000000..c981b98e --- /dev/null +++ b/Client/Pages/Authentication.razor @@ -0,0 +1,9 @@ +@page "/authentication/{action}" + + +@code{ + + [Parameter] + public string? Action { get; set; } + +} diff --git a/Client/Program.cs b/Client/Program.cs index a860d7d2..e2191d4b 100644 --- a/Client/Program.cs +++ b/Client/Program.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; using Couple.Client.Data; +using Couple.Client.Infrastructure; using Couple.Client.Services.Synchronizer; using Couple.Client.States.Done; using Couple.Client.States.Issue; using Couple.Client.Utility; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.EntityFrameworkCore; @@ -30,34 +30,35 @@ public static async Task Main(string[] args) builder.RootComponents.Add("head::after"); builder.Services - .AddTransient(_ => new HttpClient - { - BaseAddress = new(builder.Configuration["API_Prefix"] ?? builder.HostEnvironment.BaseAddress) - }) + .AddTransient(_ => new HttpClient { BaseAddress = new(builder.Configuration[Constants.ApiPrefix]!) }) .AddDbContextFactory(options => options.UseSqlite($"Filename={Constants.DatabaseFileName}")) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); - var host = builder.Build(); + const string httpClientName = "Api"; + builder.Services.AddHttpClient(httpClientName, + client => client.BaseAddress = new(builder.Configuration[Constants.ApiPrefix]!)) + .AddHttpMessageHandler(); - var httpClient = host.Services.GetRequiredService(); + builder.Services.AddScoped(sp => + sp.GetRequiredService() + .CreateClient(httpClientName)); - if (builder.HostEnvironment.IsStaging() || builder.HostEnvironment.IsProduction()) + builder.Services.AddMsalAuthentication(options => { - try - { - await httpClient.GetAsync("api/Ping"); - } - catch (HttpRequestException) - { - var navigationManager = host.Services.GetRequiredService(); - navigationManager.NavigateTo("/login", true); - return; - } - } + builder.Configuration.Bind("AzureAdB2C", options.ProviderOptions.Authentication); + + options.ProviderOptions.DefaultAccessTokenScopes.Add(ApiAuthorizationMessageHandler.Scope); + options.ProviderOptions.DefaultAccessTokenScopes.Add("openid"); + options.ProviderOptions.DefaultAccessTokenScopes.Add("offline_access"); + options.ProviderOptions.LoginMode = "redirect"; + }); + + var host = builder.Build(); await host.RunAsync(); } } diff --git a/Client/Shared/MainLayout.razor.cs b/Client/Shared/MainLayout.razor.cs index 2340120d..a008eef6 100644 --- a/Client/Shared/MainLayout.razor.cs +++ b/Client/Shared/MainLayout.razor.cs @@ -1,16 +1,35 @@ using Couple.Client.Services.Synchronizer; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; namespace Couple.Client.Shared; public partial class MainLayout { + private static bool s_isDataLoaded; + [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private Synchronizer Synchronizer { get; init; } = default!; + protected override async Task OnInitializedAsync() + { + // Should have done https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/?view=aspnetcore-6.0#require-authorization-for-the-entire-app, + // but that doesn't work + var authenticationState = await AuthenticationStateTask; + if (authenticationState.User.Identity is null || !authenticationState.User.Identity.IsAuthenticated) + { + NavigationManager.NavigateTo( + $"authentication/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}"); + } + } + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) + var authenticationState = await AuthenticationStateTask; + if (!s_isDataLoaded && authenticationState.User.Identity is not null && + authenticationState.User.Identity.IsAuthenticated) { + s_isDataLoaded = true; await Synchronizer.SynchronizeAsync(); } } diff --git a/Client/Utility/Constants.cs b/Client/Utility/Constants.cs index 45dc3bf1..f6185e59 100644 --- a/Client/Utility/Constants.cs +++ b/Client/Utility/Constants.cs @@ -3,4 +3,5 @@ public static class Constants { public const string DatabaseFileName = "app.db"; + public const string ApiPrefix = "ApiPrefix"; } diff --git a/Client/_Imports.razor b/Client/_Imports.razor index c61bb504..42faceb0 100644 --- a/Client/_Imports.razor +++ b/Client/_Imports.razor @@ -1,9 +1,12 @@ @using System.Net.Http @using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Couple.Client @using Couple.Client.Shared diff --git a/Client/wwwroot/appsettings.Development.json b/Client/wwwroot/appsettings.Development.json index a830e20e..a02b0e66 100644 --- a/Client/wwwroot/appsettings.Development.json +++ b/Client/wwwroot/appsettings.Development.json @@ -1,3 +1,8 @@ { - "API_Prefix": "http://localhost:7071/" + "ApiPrefix": "http://localhost:7071/", + "AzureAdB2C": { + "Authority": "https://couplesg.b2clogin.com/couplesg.onmicrosoft.com/B2C_1_couple_signin", + "ClientId": "bdeeb811-cb3c-4dbc-a856-2cf48789ccdf", + "ValidateAuthority": false + } } diff --git a/Client/wwwroot/appsettings.json b/Client/wwwroot/appsettings.json new file mode 100644 index 00000000..4481ce6e --- /dev/null +++ b/Client/wwwroot/appsettings.json @@ -0,0 +1,8 @@ +{ + "ApiPrefix": "https://couple-api.azurewebsites.net", + "AzureAdB2C": { + "Authority": "https://couplesg.b2clogin.com/couplesg.onmicrosoft.com/B2C_1_couple_signin", + "ClientId": "bdeeb811-cb3c-4dbc-a856-2cf48789ccdf", + "ValidateAuthority": false + } +} diff --git a/Client/wwwroot/index.html b/Client/wwwroot/index.html index 108bf54a..24890e23 100644 --- a/Client/wwwroot/index.html +++ b/Client/wwwroot/index.html @@ -23,6 +23,7 @@ + diff --git a/Client/wwwroot/staticwebapp.config.json b/Client/wwwroot/staticwebapp.config.json deleted file mode 100644 index 0ebc983a..00000000 --- a/Client/wwwroot/staticwebapp.config.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "routes": [ - { - "route": "/api/warmer", - "allowedRoles": [ - "anonymous" - ] - }, - { - "route": "/api/*", - "allowedRoles": [ - "user" - ] - }, - { - "route": "/.auth/login/aad", - "statusCode": "404" - }, - { - "route": "/.auth/login/facebook", - "statusCode": "404" - }, - { - "route": "/.auth/login/google", - "statusCode": "404" - }, - { - "route": "/.auth/login/twitter", - "statusCode": "404" - }, - { - "route": "/login", - "rewrite": "/.auth/login/github" - }, - { - "route": "/logout", - "rewrite": - "/.auth/logout?post_logout_redirect_uri=https://orange-island-0901fe000.azurestaticapps.net/login" - }, - { - "route": "/manifest.json", - "allowedRoles": [ - "anonymous" - ] - }, - { - "route": "/icons/*", - "headers": { - "Cache-Control": "public, max-age=31536000, immutable" - } - }, - { - "route": "/*", - "allowedRoles": [ - "user" - ] - } - ], - "globalHeaders": { - "X-XSS-Protection": "", - "X-Content-Type-Options": "nosniff" - }, - "navigationFallback": { - "rewrite": "index.html" - }, - "responseOverrides": { - "401": { - "redirect": "/login", - "statusCode": 302 - } - } -} diff --git a/docs/developer_guide.md b/docs/developer_guide.md index 2d0ed94c..a44506f9 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -72,7 +72,7 @@ ### Hosting -1. Azure Static Web App +1. Azure Storage ### Messaging