├── src └── Web │ ├── Components │ ├── Account │ │ ├── Pages │ │ │ ├── _Imports.razor │ │ │ ├── Manage │ │ │ │ ├── _Imports.razor │ │ │ │ ├── PersonalData.razor │ │ │ │ ├── ResetAuthenticator.razor │ │ │ │ ├── Disable2fa.razor │ │ │ │ ├── GenerateRecoveryCodes.razor │ │ │ │ ├── Index.razor │ │ │ │ ├── DeletePersonalData.razor │ │ │ │ ├── SetPassword.razor │ │ │ │ ├── TwoFactorAuthentication.razor │ │ │ │ ├── ChangePassword.razor │ │ │ │ └── Email.razor │ │ │ ├── InvalidUser.razor │ │ │ ├── InvalidPasswordReset.razor │ │ │ ├── ForgotPasswordConfirmation.razor │ │ │ ├── Lockout.razor │ │ │ ├── ResetPasswordConfirmation.razor │ │ │ ├── ConfirmEmail.razor │ │ │ ├── ConfirmEmailChange.razor │ │ │ ├── RegisterConfirmation.razor │ │ │ ├── ResendEmailConfirmation.razor │ │ │ ├── ForgotPassword.razor │ │ │ ├── LoginWithRecoveryCode.razor │ │ │ ├── LoginWith2fa.razor │ │ │ ├── ResetPassword.razor │ │ │ └── Login.razor │ │ ├── Shared │ │ │ ├── RedirectToLogin.razor │ │ │ ├── ManageLayout.razor │ │ │ ├── ShowRecoveryCodes.razor │ │ │ ├── AccountLayout.razor │ │ │ ├── StatusMessage.razor │ │ │ ├── ManageNavMenu.razor │ │ │ └── ExternalLoginPicker.razor │ │ ├── IdentityUserAccessor.cs │ │ ├── IdentityNoOpEmailSender.cs │ │ ├── IdentityRevalidatingAuthenticationStateProvider.cs │ │ ├── IdentityRedirectManager.cs │ │ └── IdentityComponentsEndpointRouteBuilderExtensions.cs │ ├── Pages │ │ ├── Home.razor │ │ ├── Auth.razor │ │ ├── Counter.razor │ │ ├── Error.razor │ │ └── Weather.razor │ ├── _Imports.razor │ ├── Routes.razor │ ├── Layout │ │ ├── MainLayout.razor │ │ ├── MainLayout.razor.css │ │ └── NavMenu.razor │ └── App.razor │ ├── wwwroot │ ├── favicon.png │ └── app.css │ ├── appsettings.Development.json │ ├── Data │ ├── ApplicationUser.cs │ └── ApplicationDbContext.cs │ ├── appsettings.json │ ├── Web.csproj │ ├── Properties │ └── launchSettings.json │ └── Program.cs ├── assets ├── architecture-diagram.png └── architecture-diagram.vsdx ├── infra ├── core │ ├── testing │ │ └── loadtesting.bicep │ ├── host │ │ ├── staticwebapp.bicep │ │ ├── appserviceplan.bicep │ │ ├── appservice-appsettings.bicep │ │ ├── aks-agent-pool.bicep │ │ ├── container-apps-environment.bicep │ │ ├── container-apps.bicep │ │ ├── functions.bicep │ │ ├── container-app-upsert.bicep │ │ ├── ai-environment.bicep │ │ ├── container-registry.bicep │ │ ├── aks-managed-cluster.bicep │ │ └── appservice.bicep │ ├── monitor │ │ ├── loganalytics.bicep │ │ ├── applicationinsights.bicep │ │ └── monitoring.bicep │ ├── database │ │ ├── cosmos │ │ │ ├── sql │ │ │ │ ├── cosmos-sql-role-assign.bicep │ │ │ │ ├── cosmos-sql-account.bicep │ │ │ │ ├── cosmos-sql-role-def.bicep │ │ │ │ └── cosmos-sql-db.bicep │ │ │ ├── mongo │ │ │ │ ├── cosmos-mongo-account.bicep │ │ │ │ └── cosmos-mongo-db.bicep │ │ │ └── cosmos-account.bicep │ │ ├── mysql │ │ │ └── flexibleserver.bicep │ │ ├── postgresql │ │ │ └── flexibleserver.bicep │ │ └── sqlserver │ │ │ └── sqlserver.bicep │ ├── security │ │ ├── keyvault-access.bicep │ │ ├── role.bicep │ │ ├── registry-access.bicep │ │ ├── configstore-access.bicep │ │ ├── keyvault-secret.bicep │ │ ├── keyvault.bicep │ │ └── aks-managed-cluster-access.bicep │ ├── networking │ │ ├── cdn-profile.bicep │ │ ├── cdn.bicep │ │ └── cdn-endpoint.bicep │ ├── config │ │ └── configstore.bicep │ ├── ai │ │ ├── cognitiveservices.bicep │ │ ├── project.bicep │ │ ├── hub.bicep │ │ └── hub-dependencies.bicep │ ├── search │ │ └── search-services.bicep │ ├── storage │ │ └── storage-account.bicep │ └── gateway │ │ └── apim.bicep ├── main.parameters.json ├── app │ └── web.bicep └── main.bicep ├── azure.yaml ├── LICENSE ├── .devcontainer └── devcontainer.json ├── .azdo └── pipelines │ └── azure-dev.yml ├── .github └── workflows │ └── azure-dev.yml └── README.md /src/Web/Components/Account/Pages/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using Web.Components.Account.Shared 2 | @layout AccountLayout 3 | -------------------------------------------------------------------------------- /src/Web/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasontaylordev/azd-blazor/HEAD/src/Web/wwwroot/favicon.png -------------------------------------------------------------------------------- /assets/architecture-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasontaylordev/azd-blazor/HEAD/assets/architecture-diagram.png -------------------------------------------------------------------------------- /assets/architecture-diagram.vsdx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasontaylordev/azd-blazor/HEAD/assets/architecture-diagram.vsdx -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/_Imports.razor: -------------------------------------------------------------------------------- 1 | @layout ManageLayout 2 | @attribute [Microsoft.AspNetCore.Authorization.Authorize] 3 | -------------------------------------------------------------------------------- /src/Web/Components/Pages/Home.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | 3 | Home 4 | 5 |

Hello, world!

6 | 7 | Welcome to your new app. 8 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/InvalidUser.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/InvalidUser" 2 | 3 | Invalid user 4 | 5 |

Invalid user

6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Web/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/InvalidPasswordReset.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/InvalidPasswordReset" 2 | 3 | Invalid password reset 4 | 5 |

Invalid password reset

6 |

7 | The password reset link is invalid. 8 |

9 | -------------------------------------------------------------------------------- /src/Web/Data/ApplicationUser.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace Web.Data; 4 | 5 | // Add profile data for application users by adding properties to the ApplicationUser class 6 | public class ApplicationUser : IdentityUser 7 | { 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ForgotPasswordConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ForgotPasswordConfirmation" 2 | 3 | Forgot password confirmation 4 | 5 |

Forgot password confirmation

6 |

7 | Please check your email to reset your password. 8 |

9 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Lockout.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Lockout" 2 | 3 | Locked out 4 | 5 |
6 |

Locked out

7 |

This account has been locked out, please try again later.

8 |
9 | -------------------------------------------------------------------------------- /src/Web/Data/ApplicationDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace Web.Data; 5 | 6 | public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) 7 | { 8 | } 9 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ResetPasswordConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResetPasswordConfirmation" 2 | Reset password confirmation 3 | 4 |

Reset password confirmation

5 |

6 | Your password has been reset. Please click here to log in. 7 |

8 | -------------------------------------------------------------------------------- /src/Web/Components/Pages/Auth.razor: -------------------------------------------------------------------------------- 1 | @page "/auth" 2 | 3 | @using Microsoft.AspNetCore.Authorization 4 | 5 | @attribute [Authorize] 6 | 7 | Auth 8 | 9 |

You are authenticated

10 | 11 | 12 | Hello @context.User.Identity?.Name! 13 | 14 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/RedirectToLogin.razor: -------------------------------------------------------------------------------- 1 | @inject NavigationManager NavigationManager 2 | 3 | @code { 4 | protected override void OnInitialized() 5 | { 6 | NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-Web-da5aff65-de20-4939-b8d1-68a22c1159ef;Trusted_Connection=True;MultipleActiveResultSets=true" 4 | }, 5 | "Logging": { 6 | "LogLevel": { 7 | "Default": "Information", 8 | "Microsoft.AspNetCore": "Warning" 9 | } 10 | }, 11 | "AllowedHosts": "*" 12 | } 13 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/ManageLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @layout AccountLayout 3 | 4 |

Manage your account

5 | 6 |
7 |

Change your account settings

8 |
9 |
10 |
11 | 12 |
13 |
14 | @Body 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /infra/core/testing/loadtesting.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param managedIdentity bool = false 4 | param tags object = {} 5 | 6 | resource loadTest 'Microsoft.LoadTestService/loadTests@2022-12-01' = { 7 | name: name 8 | location: location 9 | tags: tags 10 | identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } 11 | properties: { 12 | } 13 | } 14 | 15 | output loadTestingName string = loadTest.name 16 | -------------------------------------------------------------------------------- /src/Web/Components/Pages/Counter.razor: -------------------------------------------------------------------------------- 1 | @page "/counter" 2 | @rendermode InteractiveServer 3 | 4 | Counter 5 | 6 |

Counter

7 | 8 |

Current count: @currentCount

9 | 10 | 11 | 12 | @code { 13 | private int currentCount = 0; 14 | 15 | private void IncrementCount() 16 | { 17 | currentCount++; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Web/Components/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Authorization 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 8 | @using Microsoft.AspNetCore.Components.Web.Virtualization 9 | @using Microsoft.JSInterop 10 | @using Web 11 | @using Web.Components 12 | -------------------------------------------------------------------------------- /src/Web/Components/Routes.razor: -------------------------------------------------------------------------------- 1 | @using Web.Components.Account.Shared 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /infra/core/host/staticwebapp.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Static Web Apps instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param sku object = { 7 | name: 'Free' 8 | tier: 'Free' 9 | } 10 | 11 | resource web 'Microsoft.Web/staticSites@2022-03-01' = { 12 | name: name 13 | location: location 14 | tags: tags 15 | sku: sku 16 | properties: { 17 | provider: 'Custom' 18 | } 19 | } 20 | 21 | output name string = web.name 22 | output uri string = 'https://${web.properties.defaultHostname}' 23 | -------------------------------------------------------------------------------- /infra/core/host/appserviceplan.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure App Service plan.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param kind string = '' 7 | param reserved bool = true 8 | param sku object 9 | 10 | resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { 11 | name: name 12 | location: location 13 | tags: tags 14 | sku: sku 15 | kind: kind 16 | properties: { 17 | reserved: reserved 18 | } 19 | } 20 | 21 | output id string = appServicePlan.id 22 | output name string = appServicePlan.name 23 | -------------------------------------------------------------------------------- /infra/core/host/appservice-appsettings.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Updates app settings for an Azure App Service.' 2 | @description('The name of the app service resource within the current resource group scope') 3 | param name string 4 | 5 | @description('The app settings to be applied to the app service') 6 | @secure() 7 | param appSettings object 8 | 9 | resource appService 'Microsoft.Web/sites@2022-03-01' existing = { 10 | name: name 11 | } 12 | 13 | resource settings 'Microsoft.Web/sites/config@2022-03-01' = { 14 | name: 'appsettings' 15 | parent: appService 16 | properties: appSettings 17 | } 18 | -------------------------------------------------------------------------------- /infra/core/host/aks-agent-pool.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Adds an agent pool to an Azure Kubernetes Service (AKS) cluster.' 2 | param clusterName string 3 | 4 | @description('The agent pool name') 5 | param name string 6 | 7 | @description('The agent pool configuration') 8 | param config object 9 | 10 | resource aksCluster 'Microsoft.ContainerService/managedClusters@2023-10-02-preview' existing = { 11 | name: clusterName 12 | } 13 | 14 | resource nodePool 'Microsoft.ContainerService/managedClusters/agentPools@2023-10-02-preview' = { 15 | parent: aksCluster 16 | name: name 17 | properties: config 18 | } 19 | -------------------------------------------------------------------------------- /infra/core/monitor/loganalytics.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a Log Analytics workspace.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { 7 | name: name 8 | location: location 9 | tags: tags 10 | properties: any({ 11 | retentionInDays: 30 12 | features: { 13 | searchVersion: 1 14 | } 15 | sku: { 16 | name: 'PerGB2018' 17 | } 18 | }) 19 | } 20 | 21 | output id string = logAnalytics.id 22 | output name string = logAnalytics.name 23 | -------------------------------------------------------------------------------- /src/Web/Components/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 |
4 | 7 | 8 |
9 |
10 | About 11 |
12 | 13 |
14 | @Body 15 |
16 |
17 |
18 | 19 |
20 | An unhandled error has occurred. 21 | Reload 22 | 🗙 23 |
24 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | # This is an example starter azure.yaml file containing several example services in comments below. 4 | # Make changes as needed to describe your application setup. 5 | # To learn more about the azure.yaml file, visit https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/azd-schema 6 | 7 | # Name of the application. 8 | name: azd-blazor 9 | metadata: 10 | template: azd-blazor@0.0.3-beta 11 | services: 12 | web: 13 | language: csharp 14 | project: ./src/Web 15 | host: appservice 16 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/sql/cosmos-sql-role-assign.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a SQL role assignment under an Azure Cosmos DB account.' 2 | param accountName string 3 | 4 | param roleDefinitionId string 5 | param principalId string = '' 6 | 7 | resource role 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { 8 | parent: cosmos 9 | name: guid(roleDefinitionId, principalId, cosmos.id) 10 | properties: { 11 | principalId: principalId 12 | roleDefinitionId: roleDefinitionId 13 | scope: cosmos.id 14 | } 15 | } 16 | 17 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' existing = { 18 | name: accountName 19 | } 20 | -------------------------------------------------------------------------------- /infra/core/security/keyvault-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns an Azure Key Vault access policy.' 2 | param name string = 'add' 3 | 4 | param keyVaultName string 5 | param permissions object = { secrets: [ 'get', 'list' ] } 6 | param principalId string 7 | 8 | resource keyVaultAccessPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = { 9 | parent: keyVault 10 | name: name 11 | properties: { 12 | accessPolicies: [ { 13 | objectId: principalId 14 | tenantId: subscription().tenantId 15 | permissions: permissions 16 | } ] 17 | } 18 | } 19 | 20 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 21 | name: keyVaultName 22 | } 23 | -------------------------------------------------------------------------------- /infra/core/security/role.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a role assignment for a service principal.' 2 | param principalId string 3 | 4 | @allowed([ 5 | 'Device' 6 | 'ForeignGroup' 7 | 'Group' 8 | 'ServicePrincipal' 9 | 'User' 10 | ]) 11 | param principalType string = 'ServicePrincipal' 12 | param roleDefinitionId string 13 | 14 | resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 15 | name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) 16 | properties: { 17 | principalId: principalId 18 | principalType: principalType 19 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Web/Components/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "principalId": { 12 | "value": "${AZURE_PRINCIPAL_ID}" 13 | }, 14 | "sqlAdminPassword": { 15 | "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} sqlAdminPassword)" 16 | }, 17 | "appUserPassword": { 18 | "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} appUserPassword)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/sql/cosmos-sql-account.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cosmos DB for NoSQL account.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param keyVaultName string 7 | 8 | module cosmos '../../cosmos/cosmos-account.bicep' = { 9 | name: 'cosmos-account' 10 | params: { 11 | name: name 12 | location: location 13 | tags: tags 14 | keyVaultName: keyVaultName 15 | kind: 'GlobalDocumentDB' 16 | } 17 | } 18 | 19 | output connectionStringKey string = cosmos.outputs.connectionStringKey 20 | output endpoint string = cosmos.outputs.endpoint 21 | output id string = cosmos.outputs.id 22 | output name string = cosmos.outputs.name 23 | -------------------------------------------------------------------------------- /src/Web/Components/Account/IdentityUserAccessor.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Web.Data; 3 | 4 | namespace Web.Components.Account; 5 | 6 | internal sealed class IdentityUserAccessor(UserManager userManager, IdentityRedirectManager redirectManager) 7 | { 8 | public async Task GetRequiredUserAsync(HttpContext context) 9 | { 10 | var user = await userManager.GetUserAsync(context.User); 11 | 12 | if (user is null) 13 | { 14 | redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); 15 | } 16 | 17 | return user; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/mongo/cosmos-mongo-account.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cosmos DB for MongoDB account.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param keyVaultName string 7 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 8 | 9 | module cosmos '../../cosmos/cosmos-account.bicep' = { 10 | name: 'cosmos-account' 11 | params: { 12 | name: name 13 | location: location 14 | connectionStringKey: connectionStringKey 15 | keyVaultName: keyVaultName 16 | kind: 'MongoDB' 17 | tags: tags 18 | } 19 | } 20 | 21 | output connectionStringKey string = cosmos.outputs.connectionStringKey 22 | output endpoint string = cosmos.outputs.endpoint 23 | output id string = cosmos.outputs.id 24 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/ShowRecoveryCodes.razor: -------------------------------------------------------------------------------- 1 | 2 |

Recovery codes

3 | 11 |
12 |
13 | @foreach (var recoveryCode in RecoveryCodes) 14 | { 15 |
16 | @recoveryCode 17 |
18 | } 19 |
20 |
21 | 22 | @code { 23 | [Parameter] 24 | public string[] RecoveryCodes { get; set; } = []; 25 | 26 | [Parameter] 27 | public string? StatusMessage { get; set; } 28 | } 29 | -------------------------------------------------------------------------------- /infra/core/security/registry-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' 2 | param containerRegistryName string 3 | param principalId string 4 | 5 | var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') 6 | 7 | resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 8 | scope: containerRegistry // Use when specifying a scope that is different than the deployment scope 9 | name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) 10 | properties: { 11 | roleDefinitionId: acrPullRole 12 | principalType: 'ServicePrincipal' 13 | principalId: principalId 14 | } 15 | } 16 | 17 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { 18 | name: containerRegistryName 19 | } 20 | -------------------------------------------------------------------------------- /infra/core/security/configstore-access.bicep: -------------------------------------------------------------------------------- 1 | @description('Name of Azure App Configuration store') 2 | param configStoreName string 3 | 4 | @description('The principal ID of the service principal to assign the role to') 5 | param principalId string 6 | 7 | resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' existing = { 8 | name: configStoreName 9 | } 10 | 11 | var configStoreDataReaderRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '516239f1-63e1-4d78-a4de-a74fb236a071') 12 | 13 | resource configStoreDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 14 | name: guid(subscription().id, resourceGroup().id, principalId, configStoreDataReaderRole) 15 | scope: configStore 16 | properties: { 17 | roleDefinitionId: configStoreDataReaderRole 18 | principalId: principalId 19 | principalType: 'ServicePrincipal' 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/AccountLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @layout Web.Components.Layout.MainLayout 3 | @inject NavigationManager NavigationManager 4 | 5 | @if (HttpContext is null) 6 | { 7 |

Loading...

8 | } 9 | else 10 | { 11 | @Body 12 | } 13 | 14 | @code { 15 | [CascadingParameter] 16 | private HttpContext? HttpContext { get; set; } 17 | 18 | protected override void OnParametersSet() 19 | { 20 | if (HttpContext is null) 21 | { 22 | // If this code runs, we're currently rendering in interactive mode, so there is no HttpContext. 23 | // The identity pages need to set cookies, so they require an HttpContext. To achieve this we 24 | // must transition back from interactive mode to a server-rendered page. 25 | NavigationManager.Refresh(forceReload: true); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /infra/core/security/keyvault-secret.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates or updates a secret in an Azure Key Vault.' 2 | param name string 3 | param tags object = {} 4 | param keyVaultName string 5 | param contentType string = 'string' 6 | @description('The value of the secret. Provide only derived values like blob storage access, but do not hard code any secrets in your templates') 7 | @secure() 8 | param secretValue string 9 | 10 | param enabled bool = true 11 | param exp int = 0 12 | param nbf int = 0 13 | 14 | resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 15 | name: name 16 | tags: tags 17 | parent: keyVault 18 | properties: { 19 | attributes: { 20 | enabled: enabled 21 | exp: exp 22 | nbf: nbf 23 | } 24 | contentType: contentType 25 | value: secretValue 26 | } 27 | } 28 | 29 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 30 | name: keyVaultName 31 | } 32 | -------------------------------------------------------------------------------- /infra/core/networking/cdn-profile.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure CDN profile.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('The pricing tier of this CDN profile') 7 | @allowed([ 8 | 'Custom_Verizon' 9 | 'Premium_AzureFrontDoor' 10 | 'Premium_Verizon' 11 | 'StandardPlus_955BandWidth_ChinaCdn' 12 | 'StandardPlus_AvgBandWidth_ChinaCdn' 13 | 'StandardPlus_ChinaCdn' 14 | 'Standard_955BandWidth_ChinaCdn' 15 | 'Standard_Akamai' 16 | 'Standard_AvgBandWidth_ChinaCdn' 17 | 'Standard_AzureFrontDoor' 18 | 'Standard_ChinaCdn' 19 | 'Standard_Microsoft' 20 | 'Standard_Verizon' 21 | ]) 22 | param sku string = 'Standard_Microsoft' 23 | 24 | resource profile 'Microsoft.Cdn/profiles@2022-05-01-preview' = { 25 | name: name 26 | location: location 27 | tags: tags 28 | sku: { 29 | name: sku 30 | } 31 | } 32 | 33 | output id string = profile.id 34 | output name string = profile.name 35 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/StatusMessage.razor: -------------------------------------------------------------------------------- 1 | @if (!string.IsNullOrEmpty(DisplayMessage)) 2 | { 3 | var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success"; 4 | 7 | } 8 | 9 | @code { 10 | private string? messageFromCookie; 11 | 12 | [Parameter] 13 | public string? Message { get; set; } 14 | 15 | [CascadingParameter] 16 | private HttpContext HttpContext { get; set; } = default!; 17 | 18 | private string? DisplayMessage => Message ?? messageFromCookie; 19 | 20 | protected override void OnInitialized() 21 | { 22 | messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName]; 23 | 24 | if (messageFromCookie is not null) 25 | { 26 | HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/sql/cosmos-sql-role-def.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a SQL role definition under an Azure Cosmos DB account.' 2 | param accountName string 3 | 4 | resource roleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2022-08-15' = { 5 | parent: cosmos 6 | name: guid(cosmos.id, accountName, 'sql-role') 7 | properties: { 8 | assignableScopes: [ 9 | cosmos.id 10 | ] 11 | permissions: [ 12 | { 13 | dataActions: [ 14 | 'Microsoft.DocumentDB/databaseAccounts/readMetadata' 15 | 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' 16 | 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' 17 | ] 18 | notDataActions: [] 19 | } 20 | ] 21 | roleName: 'Reader Writer' 22 | type: 'CustomRole' 23 | } 24 | } 25 | 26 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' existing = { 27 | name: accountName 28 | } 29 | 30 | output id string = roleDefinition.id 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jason Taylor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Web/Web.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | aspnet-Web-da5aff65-de20-4939-b8d1-68a22c1159ef 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /infra/core/monitor/applicationinsights.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' 2 | param name string 3 | param dashboardName string = '' 4 | param location string = resourceGroup().location 5 | param tags object = {} 6 | param logAnalyticsWorkspaceId string 7 | 8 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { 9 | name: name 10 | location: location 11 | tags: tags 12 | kind: 'web' 13 | properties: { 14 | Application_Type: 'web' 15 | WorkspaceResourceId: logAnalyticsWorkspaceId 16 | } 17 | } 18 | 19 | module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { 20 | name: 'application-insights-dashboard' 21 | params: { 22 | name: dashboardName 23 | location: location 24 | applicationInsightsName: applicationInsights.name 25 | } 26 | } 27 | 28 | output connectionString string = applicationInsights.properties.ConnectionString 29 | output id string = applicationInsights.id 30 | output instrumentationKey string = applicationInsights.properties.InstrumentationKey 31 | output name string = applicationInsights.name 32 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure Developer CLI", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.10-bullseye", 4 | "features": { 5 | // See https://containers.dev/features for list of features 6 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 7 | }, 8 | "ghcr.io/azure/azure-dev/azd:latest": {} 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "GitHub.vscode-github-actions", 14 | "ms-azuretools.azure-dev", 15 | "ms-azuretools.vscode-azurefunctions", 16 | "ms-azuretools.vscode-bicep", 17 | "ms-azuretools.vscode-docker" 18 | // Include other VSCode language extensions if needed 19 | // Right click on an extension inside VSCode to add directly to devcontainer.json, or copy the extension ID 20 | ] 21 | } 22 | }, 23 | "forwardPorts": [ 24 | // Forward ports if needed for local development 25 | ], 26 | "postCreateCommand": "", 27 | "remoteUser": "vscode", 28 | "hostRequirements": { 29 | "memory": "8gb" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /infra/core/security/keyvault.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Key Vault.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param principalId string = '' 7 | 8 | @description('Allow the key vault to be used during resource creation.') 9 | param enabledForDeployment bool = false 10 | @description('Allow the key vault to be used for template deployment.') 11 | param enabledForTemplateDeployment bool = false 12 | 13 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { 14 | name: name 15 | location: location 16 | tags: tags 17 | properties: { 18 | tenantId: subscription().tenantId 19 | sku: { family: 'A', name: 'standard' } 20 | accessPolicies: !empty(principalId) ? [ 21 | { 22 | objectId: principalId 23 | permissions: { secrets: [ 'get', 'list' ] } 24 | tenantId: subscription().tenantId 25 | } 26 | ] : [] 27 | enabledForDeployment: enabledForDeployment 28 | enabledForTemplateDeployment: enabledForTemplateDeployment 29 | } 30 | } 31 | 32 | output endpoint string = keyVault.properties.vaultUri 33 | output id string = keyVault.id 34 | output name string = keyVault.name 35 | -------------------------------------------------------------------------------- /infra/core/security/aks-managed-cluster-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns RBAC role to the specified AKS cluster and principal.' 2 | 3 | @description('The AKS cluster name used as the target of the role assignments.') 4 | param clusterName string 5 | 6 | @description('The principal ID to assign the role to.') 7 | param principalId string 8 | 9 | @description('The principal type to assign the role to.') 10 | @allowed(['Device','ForeignGroup','Group','ServicePrincipal','User']) 11 | param principalType string = 'User' 12 | 13 | var aksClusterAdminRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b') 14 | 15 | resource aksRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 16 | scope: aksCluster // Use when specifying a scope that is different than the deployment scope 17 | name: guid(subscription().id, resourceGroup().id, principalId, aksClusterAdminRole) 18 | properties: { 19 | roleDefinitionId: aksClusterAdminRole 20 | principalType: principalType 21 | principalId: principalId 22 | } 23 | } 24 | 25 | resource aksCluster 'Microsoft.ContainerService/managedClusters@2023-10-02-preview' existing = { 26 | name: clusterName 27 | } 28 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/PersonalData.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/PersonalData" 2 | 3 | @inject IdentityUserAccessor UserAccessor 4 | 5 | Personal Data 6 | 7 | 8 |

Personal Data

9 | 10 |
11 |
12 |

Your account contains personal data that you have given us. This page allows you to download or delete that data.

13 |

14 | Deleting this data will permanently remove your account, and this cannot be recovered. 15 |

16 |
17 | 18 | 19 | 20 |

21 | Delete 22 |

23 |
24 |
25 | 26 | @code { 27 | [CascadingParameter] 28 | private HttpContext HttpContext { get; set; } = default!; 29 | 30 | protected override async Task OnInitializedAsync() 31 | { 32 | _ = await UserAccessor.GetRequiredUserAsync(HttpContext); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:14165", 8 | "sslPort": 44357 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "applicationUrl": "http://localhost:5127", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "applicationUrl": "https://localhost:7235;http://localhost:5127", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | }, 30 | "IIS Express": { 31 | "commandName": "IISExpress", 32 | "launchBrowser": true, 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Web/Components/Account/IdentityNoOpEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.Identity.UI.Services; 3 | using Web.Data; 4 | 5 | namespace Web.Components.Account; 6 | 7 | // Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. 8 | internal sealed class IdentityNoOpEmailSender : IEmailSender 9 | { 10 | private readonly IEmailSender emailSender = new NoOpEmailSender(); 11 | 12 | public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => 13 | emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); 14 | 15 | public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => 16 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); 17 | 18 | public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => 19 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); 20 | } 21 | -------------------------------------------------------------------------------- /infra/core/networking/cdn.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure CDN profile with a single endpoint.' 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | @description('Name of the CDN endpoint resource') 6 | param cdnEndpointName string 7 | 8 | @description('Name of the CDN profile resource') 9 | param cdnProfileName string 10 | 11 | @description('Delivery policy rules') 12 | param deliveryPolicyRules array = [] 13 | 14 | @description('Origin URL for the CDN endpoint') 15 | param originUrl string 16 | 17 | module cdnProfile 'cdn-profile.bicep' = { 18 | name: 'cdn-profile' 19 | params: { 20 | name: cdnProfileName 21 | location: location 22 | tags: tags 23 | } 24 | } 25 | 26 | module cdnEndpoint 'cdn-endpoint.bicep' = { 27 | name: 'cdn-endpoint' 28 | params: { 29 | name: cdnEndpointName 30 | location: location 31 | tags: tags 32 | cdnProfileName: cdnProfile.outputs.name 33 | originUrl: originUrl 34 | deliveryPolicyRules: deliveryPolicyRules 35 | } 36 | } 37 | 38 | output endpointName string = cdnEndpoint.outputs.name 39 | output endpointId string = cdnEndpoint.outputs.id 40 | output profileName string = cdnProfile.outputs.name 41 | output profileId string = cdnProfile.outputs.id 42 | output uri string = cdnEndpoint.outputs.uri 43 | -------------------------------------------------------------------------------- /infra/core/monitor/monitoring.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Application Insights instance and a Log Analytics workspace.' 2 | param logAnalyticsName string 3 | param applicationInsightsName string 4 | param applicationInsightsDashboardName string = '' 5 | param location string = resourceGroup().location 6 | param tags object = {} 7 | 8 | module logAnalytics 'loganalytics.bicep' = { 9 | name: 'loganalytics' 10 | params: { 11 | name: logAnalyticsName 12 | location: location 13 | tags: tags 14 | } 15 | } 16 | 17 | module applicationInsights 'applicationinsights.bicep' = { 18 | name: 'applicationinsights' 19 | params: { 20 | name: applicationInsightsName 21 | location: location 22 | tags: tags 23 | dashboardName: applicationInsightsDashboardName 24 | logAnalyticsWorkspaceId: logAnalytics.outputs.id 25 | } 26 | } 27 | 28 | output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString 29 | output applicationInsightsId string = applicationInsights.outputs.id 30 | output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey 31 | output applicationInsightsName string = applicationInsights.outputs.name 32 | output logAnalyticsWorkspaceId string = logAnalytics.outputs.id 33 | output logAnalyticsWorkspaceName string = logAnalytics.outputs.name 34 | -------------------------------------------------------------------------------- /src/Web/Components/Pages/Error.razor: -------------------------------------------------------------------------------- 1 | @page "/Error" 2 | @using System.Diagnostics 3 | 4 | Error 5 | 6 |

Error.

7 |

An error occurred while processing your request.

8 | 9 | @if (ShowRequestId) 10 | { 11 |

12 | Request ID: @RequestId 13 |

14 | } 15 | 16 |

Development Mode

17 |

18 | Swapping to Development environment will display more detailed information about the error that occurred. 19 |

20 |

21 | The Development environment shouldn't be enabled for deployed applications. 22 | It can result in displaying sensitive information from exceptions to end users. 23 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 24 | and restarting the app. 25 |

26 | 27 | @code{ 28 | [CascadingParameter] 29 | private HttpContext? HttpContext { get; set; } 30 | 31 | private string? RequestId { get; set; } 32 | private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 33 | 34 | protected override void OnInitialized() => 35 | RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; 36 | } 37 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/ManageNavMenu.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Identity 2 | @using Web.Data 3 | 4 | @inject SignInManager SignInManager 5 | 6 | 29 | 30 | @code { 31 | private bool hasExternalLogins; 32 | 33 | protected override async Task OnInitializedAsync() 34 | { 35 | hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/mongo/cosmos-mongo-db.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cosmos DB for MongoDB account with a database.' 2 | param accountName string 3 | param databaseName string 4 | param location string = resourceGroup().location 5 | param tags object = {} 6 | 7 | param collections array = [] 8 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 9 | param keyVaultName string 10 | 11 | module cosmos 'cosmos-mongo-account.bicep' = { 12 | name: 'cosmos-mongo-account' 13 | params: { 14 | name: accountName 15 | location: location 16 | keyVaultName: keyVaultName 17 | tags: tags 18 | connectionStringKey: connectionStringKey 19 | } 20 | } 21 | 22 | resource database 'Microsoft.DocumentDB/databaseAccounts/mongodbDatabases@2022-08-15' = { 23 | name: '${accountName}/${databaseName}' 24 | tags: tags 25 | properties: { 26 | resource: { id: databaseName } 27 | } 28 | 29 | resource list 'collections' = [for collection in collections: { 30 | name: collection.name 31 | properties: { 32 | resource: { 33 | id: collection.id 34 | shardKey: { _id: collection.shardKey } 35 | indexes: [ { key: { keys: [ collection.indexKey ] } } ] 36 | } 37 | } 38 | }] 39 | 40 | dependsOn: [ 41 | cosmos 42 | ] 43 | } 44 | 45 | output connectionStringKey string = connectionStringKey 46 | output databaseName string = databaseName 47 | output endpoint string = cosmos.outputs.endpoint 48 | -------------------------------------------------------------------------------- /infra/core/networking/cdn-endpoint.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Adds an endpoint to an Azure CDN profile.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('The name of the CDN profile resource') 7 | @minLength(1) 8 | param cdnProfileName string 9 | 10 | @description('Delivery policy rules') 11 | param deliveryPolicyRules array = [] 12 | 13 | @description('The origin URL for the endpoint') 14 | @minLength(1) 15 | param originUrl string 16 | 17 | resource endpoint 'Microsoft.Cdn/profiles/endpoints@2022-05-01-preview' = { 18 | parent: cdnProfile 19 | name: name 20 | location: location 21 | tags: tags 22 | properties: { 23 | originHostHeader: originUrl 24 | isHttpAllowed: false 25 | isHttpsAllowed: true 26 | queryStringCachingBehavior: 'UseQueryString' 27 | optimizationType: 'GeneralWebDelivery' 28 | origins: [ 29 | { 30 | name: replace(originUrl, '.', '-') 31 | properties: { 32 | hostName: originUrl 33 | originHostHeader: originUrl 34 | priority: 1 35 | weight: 1000 36 | enabled: true 37 | } 38 | } 39 | ] 40 | deliveryPolicy: { 41 | rules: deliveryPolicyRules 42 | } 43 | } 44 | } 45 | 46 | resource cdnProfile 'Microsoft.Cdn/profiles@2022-05-01-preview' existing = { 47 | name: cdnProfileName 48 | } 49 | 50 | output id string = endpoint.id 51 | output name string = endpoint.name 52 | output uri string = 'https://${endpoint.properties.hostName}' 53 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ConfirmEmail.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ConfirmEmail" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | @using Web.Data 7 | 8 | @inject UserManager UserManager 9 | @inject IdentityRedirectManager RedirectManager 10 | 11 | Confirm email 12 | 13 |

Confirm email

14 | 15 | 16 | @code { 17 | private string? statusMessage; 18 | 19 | [CascadingParameter] 20 | private HttpContext HttpContext { get; set; } = default!; 21 | 22 | [SupplyParameterFromQuery] 23 | private string? UserId { get; set; } 24 | 25 | [SupplyParameterFromQuery] 26 | private string? Code { get; set; } 27 | 28 | protected override async Task OnInitializedAsync() 29 | { 30 | if (UserId is null || Code is null) 31 | { 32 | RedirectManager.RedirectTo(""); 33 | } 34 | 35 | var user = await UserManager.FindByIdAsync(UserId); 36 | if (user is null) 37 | { 38 | HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; 39 | statusMessage = $"Error loading user with ID {UserId}"; 40 | } 41 | else 42 | { 43 | var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 44 | var result = await UserManager.ConfirmEmailAsync(user, code); 45 | statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email."; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /infra/core/host/container-apps-environment.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Apps environment.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('Name of the Application Insights resource') 7 | param applicationInsightsName string = '' 8 | 9 | @description('Specifies if Dapr is enabled') 10 | param daprEnabled bool = false 11 | 12 | @description('Name of the Log Analytics workspace') 13 | param logAnalyticsWorkspaceName string 14 | 15 | resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = { 16 | name: name 17 | location: location 18 | tags: tags 19 | properties: { 20 | appLogsConfiguration: { 21 | destination: 'log-analytics' 22 | logAnalyticsConfiguration: { 23 | customerId: logAnalyticsWorkspace.properties.customerId 24 | sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey 25 | } 26 | } 27 | daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : '' 28 | } 29 | } 30 | 31 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { 32 | name: logAnalyticsWorkspaceName 33 | } 34 | 35 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)) { 36 | name: applicationInsightsName 37 | } 38 | 39 | output defaultDomain string = containerAppsEnvironment.properties.defaultDomain 40 | output id string = containerAppsEnvironment.id 41 | output name string = containerAppsEnvironment.name 42 | -------------------------------------------------------------------------------- /infra/core/config/configstore.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure App Configuration store.' 2 | 3 | @description('The name for the Azure App Configuration store') 4 | param name string 5 | 6 | @description('The Azure region/location for the Azure App Configuration store') 7 | param location string = resourceGroup().location 8 | 9 | @description('Custom tags to apply to the Azure App Configuration store') 10 | param tags object = {} 11 | 12 | @description('Specifies the names of the key-value resources. The name is a combination of key and label with $ as delimiter. The label is optional.') 13 | param keyValueNames array = [] 14 | 15 | @description('Specifies the values of the key-value resources.') 16 | param keyValueValues array = [] 17 | 18 | @description('The principal ID to grant access to the Azure App Configuration store') 19 | param principalId string 20 | 21 | resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' = { 22 | name: name 23 | location: location 24 | sku: { 25 | name: 'standard' 26 | } 27 | tags: tags 28 | } 29 | 30 | resource configStoreKeyValue 'Microsoft.AppConfiguration/configurationStores/keyValues@2023-03-01' = [for (item, i) in keyValueNames: { 31 | parent: configStore 32 | name: item 33 | properties: { 34 | value: keyValueValues[i] 35 | tags: tags 36 | } 37 | }] 38 | 39 | module configStoreAccess '../security/configstore-access.bicep' = { 40 | name: 'app-configuration-access' 41 | params: { 42 | configStoreName: name 43 | principalId: principalId 44 | } 45 | dependsOn: [configStore] 46 | } 47 | 48 | output endpoint string = configStore.properties.endpoint 49 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Shared/ExternalLoginPicker.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Authentication 2 | @using Microsoft.AspNetCore.Identity 3 | @using Web.Data 4 | 5 | @inject SignInManager SignInManager 6 | @inject IdentityRedirectManager RedirectManager 7 | 8 | @if (externalLogins.Length == 0) 9 | { 10 |
11 |

12 | There are no external authentication services configured. See this article 13 | about setting up this ASP.NET application to support logging in via external services. 14 |

15 |
16 | } 17 | else 18 | { 19 |
20 |
21 | 22 | 23 |

24 | @foreach (var provider in externalLogins) 25 | { 26 | 27 | } 28 |

29 |
30 |
31 | } 32 | 33 | @code { 34 | private AuthenticationScheme[] externalLogins = []; 35 | 36 | [SupplyParameterFromQuery] 37 | private string? ReturnUrl { get; set; } 38 | 39 | protected override async Task OnInitializedAsync() 40 | { 41 | externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/cosmos-account.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cosmos DB account.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 7 | param keyVaultName string 8 | 9 | @allowed([ 'GlobalDocumentDB', 'MongoDB', 'Parse' ]) 10 | param kind string 11 | 12 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = { 13 | name: name 14 | kind: kind 15 | location: location 16 | tags: tags 17 | properties: { 18 | consistencyPolicy: { defaultConsistencyLevel: 'Session' } 19 | locations: [ 20 | { 21 | locationName: location 22 | failoverPriority: 0 23 | isZoneRedundant: false 24 | } 25 | ] 26 | databaseAccountOfferType: 'Standard' 27 | enableAutomaticFailover: false 28 | enableMultipleWriteLocations: false 29 | apiProperties: (kind == 'MongoDB') ? { serverVersion: '4.2' } : {} 30 | capabilities: [ { name: 'EnableServerless' } ] 31 | minimalTlsVersion: 'Tls12' 32 | } 33 | } 34 | 35 | resource cosmosConnectionString 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 36 | parent: keyVault 37 | name: connectionStringKey 38 | properties: { 39 | value: cosmos.listConnectionStrings().connectionStrings[0].connectionString 40 | } 41 | } 42 | 43 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 44 | name: keyVaultName 45 | } 46 | 47 | output connectionStringKey string = connectionStringKey 48 | output endpoint string = cosmos.properties.documentEndpoint 49 | output id string = cosmos.id 50 | output name string = cosmos.name 51 | -------------------------------------------------------------------------------- /infra/core/host/container-apps.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Registry and an Azure Container Apps environment.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param containerAppsEnvironmentName string 7 | param containerRegistryName string 8 | param containerRegistryResourceGroupName string = '' 9 | param containerRegistryAdminUserEnabled bool = false 10 | param logAnalyticsWorkspaceName string 11 | param applicationInsightsName string = '' 12 | param daprEnabled bool = false 13 | 14 | module containerAppsEnvironment 'container-apps-environment.bicep' = { 15 | name: '${name}-container-apps-environment' 16 | params: { 17 | name: containerAppsEnvironmentName 18 | location: location 19 | tags: tags 20 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 21 | applicationInsightsName: applicationInsightsName 22 | daprEnabled: daprEnabled 23 | } 24 | } 25 | 26 | module containerRegistry 'container-registry.bicep' = { 27 | name: '${name}-container-registry' 28 | scope: !empty(containerRegistryResourceGroupName) ? resourceGroup(containerRegistryResourceGroupName) : resourceGroup() 29 | params: { 30 | name: containerRegistryName 31 | location: location 32 | adminUserEnabled: containerRegistryAdminUserEnabled 33 | tags: tags 34 | } 35 | } 36 | 37 | output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain 38 | output environmentName string = containerAppsEnvironment.outputs.name 39 | output environmentId string = containerAppsEnvironment.outputs.id 40 | 41 | output registryLoginServer string = containerRegistry.outputs.loginServer 42 | output registryName string = containerRegistry.outputs.name 43 | -------------------------------------------------------------------------------- /.azdo/pipelines/azure-dev.yml: -------------------------------------------------------------------------------- 1 | # Run when commits are pushed to main 2 | on: 3 | workflow_dispatch: 4 | push: 5 | # Run when commits are pushed to mainline branch (main or master) 6 | # Set this to the mainline branch you are using 7 | branches: 8 | - main 9 | 10 | # Set up permissions for deploying with secretless Azure federated credentials 11 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 12 | permissions: 13 | id-token: write 14 | contents: read 15 | 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | env: 21 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 22 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 23 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 24 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 25 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Install azd 31 | uses: Azure/setup-azd@v2 32 | 33 | - name: Install .NET 34 | uses: actions/setup-dotnet@v4 35 | with: 36 | dotnet-version: '9.x' 37 | 38 | - name: Log in with Azure (Federated Credentials) 39 | run: | 40 | azd auth login ` 41 | --client-id "$Env:AZURE_CLIENT_ID" ` 42 | --federated-credential-provider "github" ` 43 | --tenant-id "$Env:AZURE_TENANT_ID" 44 | shell: pwsh 45 | 46 | - name: Provision Infrastructure 47 | run: azd provision --no-prompt 48 | env: 49 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 50 | 51 | - name: Deploy Application 52 | run: azd deploy --no-prompt 53 | -------------------------------------------------------------------------------- /infra/core/ai/cognitiveservices.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cognitive Services instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | @description('The custom subdomain name used to access the API. Defaults to the value of the name parameter.') 6 | param customSubDomainName string = name 7 | param disableLocalAuth bool = false 8 | param deployments array = [] 9 | param kind string = 'OpenAI' 10 | 11 | @allowed([ 'Enabled', 'Disabled' ]) 12 | param publicNetworkAccess string = 'Enabled' 13 | param sku object = { 14 | name: 'S0' 15 | } 16 | 17 | param allowedIpRules array = [] 18 | param networkAcls object = empty(allowedIpRules) ? { 19 | defaultAction: 'Allow' 20 | } : { 21 | ipRules: allowedIpRules 22 | defaultAction: 'Deny' 23 | } 24 | 25 | resource account 'Microsoft.CognitiveServices/accounts@2023-05-01' = { 26 | name: name 27 | location: location 28 | tags: tags 29 | kind: kind 30 | properties: { 31 | customSubDomainName: customSubDomainName 32 | publicNetworkAccess: publicNetworkAccess 33 | networkAcls: networkAcls 34 | disableLocalAuth: disableLocalAuth 35 | } 36 | sku: sku 37 | } 38 | 39 | @batchSize(1) 40 | resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { 41 | parent: account 42 | name: deployment.name 43 | properties: { 44 | model: deployment.model 45 | raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null 46 | } 47 | sku: contains(deployment, 'sku') ? deployment.sku : { 48 | name: 'Standard' 49 | capacity: 20 50 | } 51 | }] 52 | 53 | output endpoint string = account.properties.endpoint 54 | output endpoints object = account.properties.endpoints 55 | output id string = account.id 56 | output name string = account.name 57 | -------------------------------------------------------------------------------- /infra/core/database/mysql/flexibleserver.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Database for MySQL - Flexible Server.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param sku object 7 | param storage object 8 | param administratorLogin string 9 | @secure() 10 | param administratorLoginPassword string 11 | param highAvailabilityMode string = 'Disabled' 12 | param databaseNames array = [] 13 | param allowAzureIPsFirewall bool = false 14 | param allowAllIPsFirewall bool = false 15 | param allowedSingleIPs array = [] 16 | 17 | // MySQL version 18 | param version string 19 | 20 | resource mysqlServer 'Microsoft.DBforMySQL/flexibleServers@2023-06-30' = { 21 | location: location 22 | tags: tags 23 | name: name 24 | sku: sku 25 | properties: { 26 | version: version 27 | administratorLogin: administratorLogin 28 | administratorLoginPassword: administratorLoginPassword 29 | storage: storage 30 | highAvailability: { 31 | mode: highAvailabilityMode 32 | } 33 | } 34 | 35 | resource database 'databases' = [for name in databaseNames: { 36 | name: name 37 | }] 38 | 39 | resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { 40 | name: 'allow-all-IPs' 41 | properties: { 42 | startIpAddress: '0.0.0.0' 43 | endIpAddress: '255.255.255.255' 44 | } 45 | } 46 | 47 | resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { 48 | name: 'allow-all-azure-internal-IPs' 49 | properties: { 50 | startIpAddress: '0.0.0.0' 51 | endIpAddress: '0.0.0.0' 52 | } 53 | } 54 | 55 | resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { 56 | name: 'allow-single-${replace(ip, '.', '')}' 57 | properties: { 58 | startIpAddress: ip 59 | endIpAddress: ip 60 | } 61 | }] 62 | 63 | } 64 | 65 | output MYSQL_DOMAIN_NAME string = mysqlServer.properties.fullyQualifiedDomainName 66 | -------------------------------------------------------------------------------- /infra/core/database/postgresql/flexibleserver.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Database for PostgreSQL - Flexible Server.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param sku object 7 | param storage object 8 | param administratorLogin string 9 | @secure() 10 | param administratorLoginPassword string 11 | param databaseNames array = [] 12 | param allowAzureIPsFirewall bool = false 13 | param allowAllIPsFirewall bool = false 14 | param allowedSingleIPs array = [] 15 | 16 | // PostgreSQL version 17 | param version string 18 | 19 | // Latest official version 2022-12-01 does not have Bicep types available 20 | resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = { 21 | location: location 22 | tags: tags 23 | name: name 24 | sku: sku 25 | properties: { 26 | version: version 27 | administratorLogin: administratorLogin 28 | administratorLoginPassword: administratorLoginPassword 29 | storage: storage 30 | highAvailability: { 31 | mode: 'Disabled' 32 | } 33 | } 34 | 35 | resource database 'databases' = [for name in databaseNames: { 36 | name: name 37 | }] 38 | 39 | resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { 40 | name: 'allow-all-IPs' 41 | properties: { 42 | startIpAddress: '0.0.0.0' 43 | endIpAddress: '255.255.255.255' 44 | } 45 | } 46 | 47 | resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { 48 | name: 'allow-all-azure-internal-IPs' 49 | properties: { 50 | startIpAddress: '0.0.0.0' 51 | endIpAddress: '0.0.0.0' 52 | } 53 | } 54 | 55 | resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { 56 | name: 'allow-single-${replace(ip, '.', '')}' 57 | properties: { 58 | startIpAddress: ip 59 | endIpAddress: ip 60 | } 61 | }] 62 | 63 | } 64 | 65 | output POSTGRES_DOMAIN_NAME string = postgresServer.properties.fullyQualifiedDomainName 66 | -------------------------------------------------------------------------------- /infra/core/search/search-services.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure AI Search instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param sku object = { 7 | name: 'standard' 8 | } 9 | 10 | param authOptions object = {} 11 | param disableLocalAuth bool = false 12 | param disabledDataExfiltrationOptions array = [] 13 | param encryptionWithCmk object = { 14 | enforcement: 'Unspecified' 15 | } 16 | @allowed([ 17 | 'default' 18 | 'highDensity' 19 | ]) 20 | param hostingMode string = 'default' 21 | param networkRuleSet object = { 22 | bypass: 'None' 23 | ipRules: [] 24 | } 25 | param partitionCount int = 1 26 | @allowed([ 27 | 'enabled' 28 | 'disabled' 29 | ]) 30 | param publicNetworkAccess string = 'enabled' 31 | param replicaCount int = 1 32 | @allowed([ 33 | 'disabled' 34 | 'free' 35 | 'standard' 36 | ]) 37 | param semanticSearch string = 'disabled' 38 | 39 | var searchIdentityProvider = (sku.name == 'free') ? null : { 40 | type: 'SystemAssigned' 41 | } 42 | 43 | resource search 'Microsoft.Search/searchServices@2021-04-01-preview' = { 44 | name: name 45 | location: location 46 | tags: tags 47 | // The free tier does not support managed identity 48 | identity: searchIdentityProvider 49 | properties: { 50 | authOptions: disableLocalAuth ? null : authOptions 51 | disableLocalAuth: disableLocalAuth 52 | disabledDataExfiltrationOptions: disabledDataExfiltrationOptions 53 | encryptionWithCmk: encryptionWithCmk 54 | hostingMode: hostingMode 55 | networkRuleSet: networkRuleSet 56 | partitionCount: partitionCount 57 | publicNetworkAccess: publicNetworkAccess 58 | replicaCount: replicaCount 59 | semanticSearch: semanticSearch 60 | } 61 | sku: sku 62 | } 63 | 64 | output id string = search.id 65 | output endpoint string = 'https://${name}.search.windows.net/' 66 | output name string = search.name 67 | output principalId string = !empty(searchIdentityProvider) ? search.identity.principalId : '' 68 | 69 | -------------------------------------------------------------------------------- /infra/app/web.bicep: -------------------------------------------------------------------------------- 1 | param location string = resourceGroup().location 2 | param tags object = {} 3 | param webServiceName string 4 | param logAnalyticsName string 5 | param applicationInsightsName string 6 | param applicationInsightsDashboardName string 7 | param appServicePlanName string 8 | param appServiceName string 9 | param keyVaultName string 10 | 11 | // Provision a log analytics workspace, application insights instance and dashboard 12 | module monitoring '../core/monitor/monitoring.bicep' = { 13 | name: 'monitoring' 14 | params: { 15 | location: location 16 | tags: tags 17 | logAnalyticsName: logAnalyticsName 18 | applicationInsightsName: applicationInsightsName 19 | applicationInsightsDashboardName: applicationInsightsDashboardName 20 | } 21 | } 22 | 23 | // Provision an app service plan 24 | module appServicePlan '../core/host/appserviceplan.bicep' = { 25 | name: 'appServicePlan' 26 | params: { 27 | location: location 28 | tags: tags 29 | name: appServicePlanName 30 | sku: { 31 | name: 'B1' 32 | } 33 | kind: 'linux' 34 | } 35 | } 36 | 37 | // Provision an app service instance and add configuration for application insights and key vault 38 | module web '../core/host/appservice.bicep' = { 39 | name: 'web' 40 | params: { 41 | name: appServiceName 42 | location: location 43 | tags: union(tags, { 'azd-service-name': webServiceName }) 44 | applicationInsightsName: monitoring.outputs.applicationInsightsName 45 | appServicePlanId: appServicePlan.outputs.id 46 | keyVaultName: keyVaultName 47 | runtimeName: 'dotnetcore' 48 | runtimeVersion: '9.0' 49 | appSettings: { 50 | ASPNETCORE_ENVIRONMENT: 'Development' 51 | } 52 | } 53 | } 54 | 55 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 56 | output SERVICE_WEB_IDENTITY_PRINCIPAL_ID string = web.outputs.identityPrincipalId 57 | output SERVICE_WEB_NAME string = web.outputs.name 58 | output SERVICE_WEB_URI string = web.outputs.uri 59 | -------------------------------------------------------------------------------- /src/Web/Components/Pages/Weather.razor: -------------------------------------------------------------------------------- 1 | @page "/weather" 2 | @attribute [StreamRendering] 3 | 4 | Weather 5 | 6 |

Weather

7 | 8 |

This component demonstrates showing data.

9 | 10 | @if (forecasts == null) 11 | { 12 |

Loading...

13 | } 14 | else 15 | { 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | @foreach (var forecast in forecasts) 27 | { 28 | 29 | 30 | 31 | 32 | 33 | 34 | } 35 | 36 |
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
37 | } 38 | 39 | @code { 40 | private WeatherForecast[]? forecasts; 41 | 42 | protected override async Task OnInitializedAsync() 43 | { 44 | // Simulate asynchronous loading to demonstrate streaming rendering 45 | await Task.Delay(500); 46 | 47 | var startDate = DateOnly.FromDateTime(DateTime.Now); 48 | var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; 49 | forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast 50 | { 51 | Date = startDate.AddDays(index), 52 | TemperatureC = Random.Shared.Next(-20, 55), 53 | Summary = summaries[Random.Shared.Next(summaries.Length)] 54 | }).ToArray(); 55 | } 56 | 57 | private class WeatherForecast 58 | { 59 | public DateOnly Date { get; set; } 60 | public int TemperatureC { get; set; } 61 | public string? Summary { get; set; } 62 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Web/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Components.Authorization; 3 | using Microsoft.AspNetCore.Components.Server; 4 | using Microsoft.AspNetCore.Identity; 5 | using Microsoft.Extensions.Options; 6 | using Web.Data; 7 | 8 | namespace Web.Components.Account; 9 | 10 | // This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user 11 | // every 30 minutes an interactive circuit is connected. 12 | internal sealed class IdentityRevalidatingAuthenticationStateProvider( 13 | ILoggerFactory loggerFactory, 14 | IServiceScopeFactory scopeFactory, 15 | IOptions options) 16 | : RevalidatingServerAuthenticationStateProvider(loggerFactory) 17 | { 18 | protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); 19 | 20 | protected override async Task ValidateAuthenticationStateAsync( 21 | AuthenticationState authenticationState, CancellationToken cancellationToken) 22 | { 23 | // Get the user manager from a new scope to ensure it fetches fresh data 24 | await using var scope = scopeFactory.CreateAsyncScope(); 25 | var userManager = scope.ServiceProvider.GetRequiredService>(); 26 | return await ValidateSecurityStampAsync(userManager, authenticationState.User); 27 | } 28 | 29 | private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) 30 | { 31 | var user = await userManager.GetUserAsync(principal); 32 | if (user is null) 33 | { 34 | return false; 35 | } 36 | else if (!userManager.SupportsUserSecurityStamp) 37 | { 38 | return true; 39 | } 40 | else 41 | { 42 | var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); 43 | var userStamp = await userManager.GetSecurityStampAsync(user); 44 | return principalStamp == userStamp; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /infra/core/database/cosmos/sql/cosmos-sql-db.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cosmos DB for NoSQL account with a database.' 2 | param accountName string 3 | param databaseName string 4 | param location string = resourceGroup().location 5 | param tags object = {} 6 | 7 | param containers array = [] 8 | param keyVaultName string 9 | param principalIds array = [] 10 | 11 | module cosmos 'cosmos-sql-account.bicep' = { 12 | name: 'cosmos-sql-account' 13 | params: { 14 | name: accountName 15 | location: location 16 | tags: tags 17 | keyVaultName: keyVaultName 18 | } 19 | } 20 | 21 | resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15' = { 22 | name: '${accountName}/${databaseName}' 23 | properties: { 24 | resource: { id: databaseName } 25 | } 26 | 27 | resource list 'containers' = [for container in containers: { 28 | name: container.name 29 | properties: { 30 | resource: { 31 | id: container.id 32 | partitionKey: { paths: [ container.partitionKey ] } 33 | } 34 | options: {} 35 | } 36 | }] 37 | 38 | dependsOn: [ 39 | cosmos 40 | ] 41 | } 42 | 43 | module roleDefinition 'cosmos-sql-role-def.bicep' = { 44 | name: 'cosmos-sql-role-definition' 45 | params: { 46 | accountName: accountName 47 | } 48 | dependsOn: [ 49 | cosmos 50 | database 51 | ] 52 | } 53 | 54 | // We need batchSize(1) here because sql role assignments have to be done sequentially 55 | @batchSize(1) 56 | module userRole 'cosmos-sql-role-assign.bicep' = [for principalId in principalIds: if (!empty(principalId)) { 57 | name: 'cosmos-sql-user-role-${uniqueString(principalId)}' 58 | params: { 59 | accountName: accountName 60 | roleDefinitionId: roleDefinition.outputs.id 61 | principalId: principalId 62 | } 63 | dependsOn: [ 64 | cosmos 65 | database 66 | ] 67 | }] 68 | 69 | output accountId string = cosmos.outputs.id 70 | output accountName string = cosmos.outputs.name 71 | output connectionStringKey string = cosmos.outputs.connectionStringKey 72 | output databaseName string = databaseName 73 | output endpoint string = cosmos.outputs.endpoint 74 | output roleDefinitionId string = roleDefinition.outputs.id 75 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/ResetAuthenticator.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/ResetAuthenticator" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | @using Web.Data 5 | 6 | @inject UserManager UserManager 7 | @inject SignInManager SignInManager 8 | @inject IdentityUserAccessor UserAccessor 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Reset authenticator key 13 | 14 | 15 |

Reset authenticator key

16 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 | @code { 34 | [CascadingParameter] 35 | private HttpContext HttpContext { get; set; } = default!; 36 | 37 | private async Task OnSubmitAsync() 38 | { 39 | var user = await UserAccessor.GetRequiredUserAsync(HttpContext); 40 | await UserManager.SetTwoFactorEnabledAsync(user, false); 41 | await UserManager.ResetAuthenticatorKeyAsync(user); 42 | var userId = await UserManager.GetUserIdAsync(user); 43 | Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); 44 | 45 | await SignInManager.RefreshSignInAsync(user); 46 | 47 | RedirectManager.RedirectToWithStatus( 48 | "Account/Manage/EnableAuthenticator", 49 | "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.", 50 | HttpContext); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Web/Components/Layout/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row ::deep .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | text-decoration: none; 28 | } 29 | 30 | .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { 31 | text-decoration: underline; 32 | } 33 | 34 | .top-row ::deep a:first-child { 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | } 38 | 39 | @media (max-width: 640.98px) { 40 | .top-row { 41 | justify-content: space-between; 42 | } 43 | 44 | .top-row ::deep a, .top-row ::deep .btn-link { 45 | margin-left: 0; 46 | } 47 | } 48 | 49 | @media (min-width: 641px) { 50 | .page { 51 | flex-direction: row; 52 | } 53 | 54 | .sidebar { 55 | width: 250px; 56 | height: 100vh; 57 | position: sticky; 58 | top: 0; 59 | } 60 | 61 | .top-row { 62 | position: sticky; 63 | top: 0; 64 | z-index: 1; 65 | } 66 | 67 | .top-row.auth ::deep a:first-child { 68 | flex: 1; 69 | text-align: right; 70 | width: 0; 71 | } 72 | 73 | .top-row, article { 74 | padding-left: 2rem !important; 75 | padding-right: 1.5rem !important; 76 | } 77 | } 78 | 79 | #blazor-error-ui { 80 | background: lightyellow; 81 | bottom: 0; 82 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 83 | display: none; 84 | left: 0; 85 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 86 | position: fixed; 87 | width: 100%; 88 | z-index: 1000; 89 | } 90 | 91 | #blazor-error-ui .dismiss { 92 | cursor: pointer; 93 | position: absolute; 94 | right: 0.75rem; 95 | top: 0.5rem; 96 | } 97 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ConfirmEmailChange.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ConfirmEmailChange" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | @using Web.Data 7 | 8 | @inject UserManager UserManager 9 | @inject SignInManager SignInManager 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Confirm email change 13 | 14 |

Confirm email change

15 | 16 | 17 | 18 | @code { 19 | private string? message; 20 | 21 | [CascadingParameter] 22 | private HttpContext HttpContext { get; set; } = default!; 23 | 24 | [SupplyParameterFromQuery] 25 | private string? UserId { get; set; } 26 | 27 | [SupplyParameterFromQuery] 28 | private string? Email { get; set; } 29 | 30 | [SupplyParameterFromQuery] 31 | private string? Code { get; set; } 32 | 33 | protected override async Task OnInitializedAsync() 34 | { 35 | if (UserId is null || Email is null || Code is null) 36 | { 37 | RedirectManager.RedirectToWithStatus( 38 | "Account/Login", "Error: Invalid email change confirmation link.", HttpContext); 39 | } 40 | 41 | var user = await UserManager.FindByIdAsync(UserId); 42 | if (user is null) 43 | { 44 | message = "Unable to find user with Id '{userId}'"; 45 | return; 46 | } 47 | 48 | var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 49 | var result = await UserManager.ChangeEmailAsync(user, Email, code); 50 | if (!result.Succeeded) 51 | { 52 | message = "Error changing email."; 53 | return; 54 | } 55 | 56 | // In our UI email and user name are one and the same, so when we update the email 57 | // we need to update the user name. 58 | var setUserNameResult = await UserManager.SetUserNameAsync(user, Email); 59 | if (!setUserNameResult.Succeeded) 60 | { 61 | message = "Error changing user name."; 62 | return; 63 | } 64 | 65 | await SignInManager.RefreshSignInAsync(user); 66 | message = "Thank you for confirming your email change."; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | # Run when commits are pushed to mainline branch (main or master) 5 | # Set this to the mainline branch you are using 6 | branches: 7 | - main 8 | - master 9 | 10 | # GitHub Actions workflow to deploy to Azure using azd 11 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config` 12 | 13 | # Set up permissions for deploying with secretless Azure federated credentials 14 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | env: 23 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 24 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 25 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 26 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 27 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Install azd 33 | uses: Azure/setup-azd@v2 34 | 35 | - name: Log in with Azure (Federated Credentials) 36 | if: ${{ env.AZURE_CLIENT_ID != '' }} 37 | run: | 38 | azd auth login ` 39 | --client-id "$Env:AZURE_CLIENT_ID" ` 40 | --federated-credential-provider "github" ` 41 | --tenant-id "$Env:AZURE_TENANT_ID" 42 | shell: pwsh 43 | 44 | - name: Log in with Azure (Client Credentials) 45 | if: ${{ env.AZURE_CREDENTIALS != '' }} 46 | run: | 47 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 48 | Write-Host "::add-mask::$($info.clientSecret)" 49 | 50 | azd auth login ` 51 | --client-id "$($info.clientId)" ` 52 | --client-secret "$($info.clientSecret)" ` 53 | --tenant-id "$($info.tenantId)" 54 | shell: pwsh 55 | env: 56 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 57 | 58 | - name: Provision Infrastructure 59 | run: azd provision --no-prompt 60 | env: 61 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 62 | 63 | - name: Deploy Application 64 | run: azd deploy --no-prompt -------------------------------------------------------------------------------- /src/Web/Components/Account/IdentityRedirectManager.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Microsoft.AspNetCore.Components; 3 | 4 | namespace Web.Components.Account; 5 | 6 | internal sealed class IdentityRedirectManager(NavigationManager navigationManager) 7 | { 8 | public const string StatusCookieName = "Identity.StatusMessage"; 9 | 10 | private static readonly CookieBuilder StatusCookieBuilder = new() 11 | { 12 | SameSite = SameSiteMode.Strict, 13 | HttpOnly = true, 14 | IsEssential = true, 15 | MaxAge = TimeSpan.FromSeconds(5), 16 | }; 17 | 18 | [DoesNotReturn] 19 | public void RedirectTo(string? uri) 20 | { 21 | uri ??= ""; 22 | 23 | // Prevent open redirects. 24 | if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) 25 | { 26 | uri = navigationManager.ToBaseRelativePath(uri); 27 | } 28 | 29 | // During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect. 30 | // So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown. 31 | navigationManager.NavigateTo(uri); 32 | throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering."); 33 | } 34 | 35 | [DoesNotReturn] 36 | public void RedirectTo(string uri, Dictionary queryParameters) 37 | { 38 | var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); 39 | var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); 40 | RedirectTo(newUri); 41 | } 42 | 43 | [DoesNotReturn] 44 | public void RedirectToWithStatus(string uri, string message, HttpContext context) 45 | { 46 | context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context)); 47 | RedirectTo(uri); 48 | } 49 | 50 | private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); 51 | 52 | [DoesNotReturn] 53 | public void RedirectToCurrentPage() => RedirectTo(CurrentPath); 54 | 55 | [DoesNotReturn] 56 | public void RedirectToCurrentPageWithStatus(string message, HttpContext context) 57 | => RedirectToWithStatus(CurrentPath, message, context); 58 | } 59 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/Disable2fa.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/Disable2fa" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | @using Web.Data 5 | 6 | @inject UserManager UserManager 7 | @inject IdentityUserAccessor UserAccessor 8 | @inject IdentityRedirectManager RedirectManager 9 | @inject ILogger Logger 10 | 11 | Disable two-factor authentication (2FA) 12 | 13 | 14 |

Disable two-factor authentication (2FA)

15 | 16 | 25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 | @code { 34 | private ApplicationUser user = default!; 35 | 36 | [CascadingParameter] 37 | private HttpContext HttpContext { get; set; } = default!; 38 | 39 | protected override async Task OnInitializedAsync() 40 | { 41 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 42 | 43 | if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) 44 | { 45 | throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); 46 | } 47 | } 48 | 49 | private async Task OnSubmitAsync() 50 | { 51 | var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false); 52 | if (!disable2faResult.Succeeded) 53 | { 54 | throw new InvalidOperationException("Unexpected error occurred disabling 2FA."); 55 | } 56 | 57 | var userId = await UserManager.GetUserIdAsync(user); 58 | Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId); 59 | RedirectManager.RedirectToWithStatus( 60 | "Account/Manage/TwoFactorAuthentication", 61 | "2fa has been disabled. You can reenable 2fa when you setup an authenticator app", 62 | HttpContext); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /infra/core/ai/project.bicep: -------------------------------------------------------------------------------- 1 | @description('The AI Studio Hub Resource name') 2 | param name string 3 | @description('The display name of the AI Studio Hub Resource') 4 | param displayName string = name 5 | @description('The name of the AI Studio Hub Resource where this project should be created') 6 | param hubName string 7 | @description('The name of the key vault resource to grant access to the project') 8 | param keyVaultName string 9 | 10 | @description('The SKU name to use for the AI Studio Hub Resource') 11 | param skuName string = 'Basic' 12 | @description('The SKU tier to use for the AI Studio Hub Resource') 13 | @allowed(['Basic', 'Free', 'Premium', 'Standard']) 14 | param skuTier string = 'Basic' 15 | @description('The public network access setting to use for the AI Studio Hub Resource') 16 | @allowed(['Enabled','Disabled']) 17 | param publicNetworkAccess string = 'Enabled' 18 | 19 | param location string = resourceGroup().location 20 | param tags object = {} 21 | 22 | resource project 'Microsoft.MachineLearningServices/workspaces@2024-01-01-preview' = { 23 | name: name 24 | location: location 25 | tags: tags 26 | sku: { 27 | name: skuName 28 | tier: skuTier 29 | } 30 | kind: 'Project' 31 | identity: { 32 | type: 'SystemAssigned' 33 | } 34 | properties: { 35 | friendlyName: displayName 36 | hbiWorkspace: false 37 | v1LegacyMode: false 38 | publicNetworkAccess: publicNetworkAccess 39 | hubResourceId: hub.id 40 | } 41 | } 42 | 43 | module keyVaultAccess '../security/keyvault-access.bicep' = { 44 | name: 'keyvault-access' 45 | params: { 46 | keyVaultName: keyVaultName 47 | principalId: project.identity.principalId 48 | } 49 | } 50 | 51 | module mlServiceRoleDataScientist '../security/role.bicep' = { 52 | name: 'ml-service-role-data-scientist' 53 | params: { 54 | principalId: project.identity.principalId 55 | roleDefinitionId: 'f6c7c914-8db3-469d-8ca1-694a8f32e121' 56 | principalType: 'ServicePrincipal' 57 | } 58 | } 59 | 60 | module mlServiceRoleSecretsReader '../security/role.bicep' = { 61 | name: 'ml-service-role-secrets-reader' 62 | params: { 63 | principalId: project.identity.principalId 64 | roleDefinitionId: 'ea01e6af-a1c1-4350-9563-ad00f8c72ec5' 65 | principalType: 'ServicePrincipal' 66 | } 67 | } 68 | 69 | resource hub 'Microsoft.MachineLearningServices/workspaces@2024-01-01-preview' existing = { 70 | name: hubName 71 | } 72 | 73 | output id string = project.id 74 | output name string = project.name 75 | output principalId string = project.identity.principalId 76 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/RegisterConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/RegisterConfirmation" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | @using Web.Data 7 | 8 | @inject UserManager UserManager 9 | @inject IEmailSender EmailSender 10 | @inject NavigationManager NavigationManager 11 | @inject IdentityRedirectManager RedirectManager 12 | 13 | Register confirmation 14 | 15 |

Register confirmation

16 | 17 | 18 | 19 | @if (emailConfirmationLink is not null) 20 | { 21 |

22 | This app does not currently have a real email sender registered, see these docs for how to configure a real email sender. 23 | Normally this would be emailed: Click here to confirm your account 24 |

25 | } 26 | else 27 | { 28 |

Please check your email to confirm your account.

29 | } 30 | 31 | @code { 32 | private string? emailConfirmationLink; 33 | private string? statusMessage; 34 | 35 | [CascadingParameter] 36 | private HttpContext HttpContext { get; set; } = default!; 37 | 38 | [SupplyParameterFromQuery] 39 | private string? Email { get; set; } 40 | 41 | [SupplyParameterFromQuery] 42 | private string? ReturnUrl { get; set; } 43 | 44 | protected override async Task OnInitializedAsync() 45 | { 46 | if (Email is null) 47 | { 48 | RedirectManager.RedirectTo(""); 49 | } 50 | 51 | var user = await UserManager.FindByEmailAsync(Email); 52 | if (user is null) 53 | { 54 | HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; 55 | statusMessage = "Error finding user for unspecified email"; 56 | } 57 | else if (EmailSender is IdentityNoOpEmailSender) 58 | { 59 | // Once you add a real email sender, you should remove this code that lets you confirm the account 60 | var userId = await UserManager.GetUserIdAsync(user); 61 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 62 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 63 | emailConfirmationLink = NavigationManager.GetUriWithQueryParameters( 64 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 65 | new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Web/wwwroot/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | } 4 | 5 | a, .btn-link { 6 | color: #006bb7; 7 | } 8 | 9 | .btn-primary { 10 | color: #fff; 11 | background-color: #1b6ec2; 12 | border-color: #1861ac; 13 | } 14 | 15 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 16 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 17 | } 18 | 19 | .content { 20 | padding-top: 1.1rem; 21 | } 22 | 23 | h1:focus { 24 | outline: none; 25 | } 26 | 27 | .valid.modified:not([type=checkbox]) { 28 | outline: 1px solid #26b050; 29 | } 30 | 31 | .invalid { 32 | outline: 1px solid #e50000; 33 | } 34 | 35 | .validation-message { 36 | color: #e50000; 37 | } 38 | 39 | .blazor-error-boundary { 40 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 41 | padding: 1rem 1rem 1rem 3.7rem; 42 | color: white; 43 | } 44 | 45 | .blazor-error-boundary::after { 46 | content: "An error has occurred." 47 | } 48 | 49 | .darker-border-checkbox.form-check-input { 50 | border-color: #929292; 51 | } 52 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/GenerateRecoveryCodes" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | @using Web.Data 5 | 6 | @inject UserManager UserManager 7 | @inject IdentityUserAccessor UserAccessor 8 | @inject IdentityRedirectManager RedirectManager 9 | @inject ILogger Logger 10 | 11 | Generate two-factor authentication (2FA) recovery codes 12 | 13 | @if (recoveryCodes is not null) 14 | { 15 | 16 | } 17 | else 18 | { 19 |

Generate two-factor authentication (2FA) recovery codes

20 | 33 |
34 |
35 | 36 | 37 | 38 |
39 | } 40 | 41 | @code { 42 | private string? message; 43 | private ApplicationUser user = default!; 44 | private IEnumerable? recoveryCodes; 45 | 46 | [CascadingParameter] 47 | private HttpContext HttpContext { get; set; } = default!; 48 | 49 | protected override async Task OnInitializedAsync() 50 | { 51 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 52 | 53 | var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user); 54 | if (!isTwoFactorEnabled) 55 | { 56 | throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); 57 | } 58 | } 59 | 60 | private async Task OnSubmitAsync() 61 | { 62 | var userId = await UserManager.GetUserIdAsync(user); 63 | recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); 64 | message = "You have generated new recovery codes."; 65 | 66 | Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ResendEmailConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResendEmailConfirmation" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | @using Web.Data 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject NavigationManager NavigationManager 13 | @inject IdentityRedirectManager RedirectManager 14 | 15 | Resend email confirmation 16 | 17 |

Resend email confirmation

18 |

Enter your email.

19 |
20 | 21 |
22 |
23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | @code { 37 | private string? message; 38 | 39 | [SupplyParameterFromForm] 40 | private InputModel Input { get; set; } = new(); 41 | 42 | private async Task OnValidSubmitAsync() 43 | { 44 | var user = await UserManager.FindByEmailAsync(Input.Email!); 45 | if (user is null) 46 | { 47 | message = "Verification email sent. Please check your email."; 48 | return; 49 | } 50 | 51 | var userId = await UserManager.GetUserIdAsync(user); 52 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 53 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 54 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 55 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 56 | new Dictionary { ["userId"] = userId, ["code"] = code }); 57 | await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); 58 | 59 | message = "Verification email sent. Please check your email."; 60 | } 61 | 62 | private sealed class InputModel 63 | { 64 | [Required] 65 | [EmailAddress] 66 | public string Email { get; set; } = ""; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ForgotPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ForgotPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | @using Web.Data 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject NavigationManager NavigationManager 13 | @inject IdentityRedirectManager RedirectManager 14 | 15 | Forgot your password? 16 | 17 |

Forgot your password?

18 |

Enter your email.

19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | @code { 37 | [SupplyParameterFromForm] 38 | private InputModel Input { get; set; } = new(); 39 | 40 | private async Task OnValidSubmitAsync() 41 | { 42 | var user = await UserManager.FindByEmailAsync(Input.Email); 43 | if (user is null || !(await UserManager.IsEmailConfirmedAsync(user))) 44 | { 45 | // Don't reveal that the user does not exist or is not confirmed 46 | RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); 47 | } 48 | 49 | // For more information on how to enable account confirmation and password reset please 50 | // visit https://go.microsoft.com/fwlink/?LinkID=532713 51 | var code = await UserManager.GeneratePasswordResetTokenAsync(user); 52 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 53 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 54 | NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri, 55 | new Dictionary { ["code"] = code }); 56 | 57 | await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); 58 | 59 | RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); 60 | } 61 | 62 | private sealed class InputModel 63 | { 64 | [Required] 65 | [EmailAddress] 66 | public string Email { get; set; } = ""; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Profile 13 | 14 |

Profile

15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | @code { 37 | private ApplicationUser user = default!; 38 | private string? username; 39 | private string? phoneNumber; 40 | 41 | [CascadingParameter] 42 | private HttpContext HttpContext { get; set; } = default!; 43 | 44 | [SupplyParameterFromForm] 45 | private InputModel Input { get; set; } = new(); 46 | 47 | protected override async Task OnInitializedAsync() 48 | { 49 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 50 | username = await UserManager.GetUserNameAsync(user); 51 | phoneNumber = await UserManager.GetPhoneNumberAsync(user); 52 | 53 | Input.PhoneNumber ??= phoneNumber; 54 | } 55 | 56 | private async Task OnValidSubmitAsync() 57 | { 58 | if (Input.PhoneNumber != phoneNumber) 59 | { 60 | var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber); 61 | if (!setPhoneResult.Succeeded) 62 | { 63 | RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext); 64 | } 65 | } 66 | 67 | await SignInManager.RefreshSignInAsync(user); 68 | RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext); 69 | } 70 | 71 | private sealed class InputModel 72 | { 73 | [Phone] 74 | [Display(Name = "Phone number")] 75 | public string? PhoneNumber { get; set; } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/DeletePersonalData.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/DeletePersonalData" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | @inject ILogger Logger 12 | 13 | Delete Personal Data 14 | 15 | 16 | 17 |

Delete Personal Data

18 | 19 | 24 | 25 |
26 | 27 | 28 | 29 | @if (requirePassword) 30 | { 31 |
32 | 33 | 34 | 35 |
36 | } 37 | 38 |
39 |
40 | 41 | @code { 42 | private string? message; 43 | private ApplicationUser user = default!; 44 | private bool requirePassword; 45 | 46 | [CascadingParameter] 47 | private HttpContext HttpContext { get; set; } = default!; 48 | 49 | [SupplyParameterFromForm] 50 | private InputModel Input { get; set; } = new(); 51 | 52 | protected override async Task OnInitializedAsync() 53 | { 54 | Input ??= new(); 55 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 56 | requirePassword = await UserManager.HasPasswordAsync(user); 57 | } 58 | 59 | private async Task OnValidSubmitAsync() 60 | { 61 | if (requirePassword && !await UserManager.CheckPasswordAsync(user, Input.Password)) 62 | { 63 | message = "Error: Incorrect password."; 64 | return; 65 | } 66 | 67 | var result = await UserManager.DeleteAsync(user); 68 | if (!result.Succeeded) 69 | { 70 | throw new InvalidOperationException("Unexpected error occurred deleting user."); 71 | } 72 | 73 | await SignInManager.SignOutAsync(); 74 | 75 | var userId = await UserManager.GetUserIdAsync(user); 76 | Logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId); 77 | 78 | RedirectManager.RedirectToCurrentPage(); 79 | } 80 | 81 | private sealed class InputModel 82 | { 83 | [DataType(DataType.Password)] 84 | public string Password { get; set; } = ""; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Web/Program.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | using Microsoft.AspNetCore.Components.Authorization; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.EntityFrameworkCore; 5 | using Web.Components; 6 | using Web.Components.Account; 7 | using Web.Data; 8 | 9 | var builder = WebApplication.CreateBuilder(args); 10 | 11 | // Add services to the container. 12 | var hasKeyVault = !string.IsNullOrEmpty(builder.Configuration["AZURE_KEY_VAULT_ENDPOINT"]); 13 | if (hasKeyVault) 14 | { 15 | builder.Configuration.AddAzureKeyVault( 16 | new Uri(builder.Configuration["AZURE_KEY_VAULT_ENDPOINT"]!), 17 | new DefaultAzureCredential()); 18 | } 19 | 20 | builder.Services.AddApplicationInsightsTelemetry(builder.Configuration); 21 | 22 | builder.Services.AddRazorComponents() 23 | .AddInteractiveServerComponents(); 24 | 25 | builder.Services.AddCascadingAuthenticationState(); 26 | builder.Services.AddScoped(); 27 | builder.Services.AddScoped(); 28 | builder.Services.AddScoped(); 29 | 30 | builder.Services.AddAuthentication(options => 31 | { 32 | options.DefaultScheme = IdentityConstants.ApplicationScheme; 33 | options.DefaultSignInScheme = IdentityConstants.ExternalScheme; 34 | }) 35 | .AddIdentityCookies(); 36 | 37 | var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); 38 | builder.Services.AddDbContext(options => 39 | options.UseSqlServer(connectionString)); 40 | builder.Services.AddDatabaseDeveloperPageExceptionFilter(); 41 | 42 | builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) 43 | .AddEntityFrameworkStores() 44 | .AddSignInManager() 45 | .AddDefaultTokenProviders(); 46 | 47 | builder.Services.AddSingleton, IdentityNoOpEmailSender>(); 48 | 49 | var app = builder.Build(); 50 | 51 | // Ensure the database is created and up-to-date. 52 | // Learn more at https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying?tabs=dotnet-core-cli#apply-migrations-at-runtime 53 | await using (var scope = app.Services.CreateAsyncScope()) 54 | { 55 | var db = scope.ServiceProvider.GetRequiredService(); 56 | await db.Database.MigrateAsync(); 57 | } 58 | 59 | // Configure the HTTP request pipeline. 60 | if (app.Environment.IsDevelopment()) 61 | { 62 | app.UseMigrationsEndPoint(); 63 | } 64 | else 65 | { 66 | app.UseExceptionHandler("/Error", createScopeForErrors: true); 67 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 68 | app.UseHsts(); 69 | } 70 | 71 | app.UseHttpsRedirection(); 72 | 73 | app.UseAntiforgery(); 74 | 75 | app.MapStaticAssets(); 76 | app.MapRazorComponents() 77 | .AddInteractiveServerRenderMode(); 78 | 79 | // Add additional endpoints required by the Identity /Account Razor components. 80 | app.MapAdditionalIdentityEndpoints(); 81 | 82 | app.Run(); 83 | -------------------------------------------------------------------------------- /infra/core/storage/storage-account.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure storage account.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @allowed([ 7 | 'Cool' 8 | 'Hot' 9 | 'Premium' ]) 10 | param accessTier string = 'Hot' 11 | param allowBlobPublicAccess bool = true 12 | param allowCrossTenantReplication bool = true 13 | param allowSharedKeyAccess bool = true 14 | param containers array = [] 15 | param corsRules array = [] 16 | param defaultToOAuthAuthentication bool = false 17 | param deleteRetentionPolicy object = {} 18 | @allowed([ 'AzureDnsZone', 'Standard' ]) 19 | param dnsEndpointType string = 'Standard' 20 | param files array = [] 21 | param isHnsEnabled bool = false 22 | param kind string = 'StorageV2' 23 | param minimumTlsVersion string = 'TLS1_2' 24 | param queues array = [] 25 | param shareDeleteRetentionPolicy object = {} 26 | param supportsHttpsTrafficOnly bool = true 27 | param tables array = [] 28 | param networkAcls object = { 29 | bypass: 'AzureServices' 30 | defaultAction: 'Allow' 31 | } 32 | @allowed([ 'Enabled', 'Disabled' ]) 33 | param publicNetworkAccess string = 'Enabled' 34 | param sku object = { name: 'Standard_LRS' } 35 | 36 | resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { 37 | name: name 38 | location: location 39 | tags: tags 40 | kind: kind 41 | sku: sku 42 | properties: { 43 | accessTier: accessTier 44 | allowBlobPublicAccess: allowBlobPublicAccess 45 | allowCrossTenantReplication: allowCrossTenantReplication 46 | allowSharedKeyAccess: allowSharedKeyAccess 47 | defaultToOAuthAuthentication: defaultToOAuthAuthentication 48 | dnsEndpointType: dnsEndpointType 49 | isHnsEnabled: isHnsEnabled 50 | minimumTlsVersion: minimumTlsVersion 51 | networkAcls: networkAcls 52 | publicNetworkAccess: publicNetworkAccess 53 | supportsHttpsTrafficOnly: supportsHttpsTrafficOnly 54 | } 55 | 56 | resource blobServices 'blobServices' = if (!empty(containers)) { 57 | name: 'default' 58 | properties: { 59 | cors: { 60 | corsRules: corsRules 61 | } 62 | deleteRetentionPolicy: deleteRetentionPolicy 63 | } 64 | resource container 'containers' = [for container in containers: { 65 | name: container.name 66 | properties: { 67 | publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' 68 | } 69 | }] 70 | } 71 | 72 | resource fileServices 'fileServices' = if (!empty(files)) { 73 | name: 'default' 74 | properties: { 75 | cors: { 76 | corsRules: corsRules 77 | } 78 | shareDeleteRetentionPolicy: shareDeleteRetentionPolicy 79 | } 80 | } 81 | 82 | resource queueServices 'queueServices' = if (!empty(queues)) { 83 | name: 'default' 84 | properties: { 85 | 86 | } 87 | resource queue 'queues' = [for queue in queues: { 88 | name: queue.name 89 | properties: { 90 | metadata: {} 91 | } 92 | }] 93 | } 94 | 95 | resource tableServices 'tableServices' = if (!empty(tables)) { 96 | name: 'default' 97 | properties: {} 98 | } 99 | } 100 | 101 | output id string = storage.id 102 | output name string = storage.name 103 | output primaryEndpoints object = storage.properties.primaryEndpoints 104 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/LoginWithRecoveryCode.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/LoginWithRecoveryCode" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject SignInManager SignInManager 8 | @inject UserManager UserManager 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Recovery code verification 13 | 14 |

Recovery code verification

15 |
16 | 17 |

18 | You have requested to log in with a recovery code. This login will not be remembered until you provide 19 | an authenticator app code at log in or disable 2FA and log in again. 20 |

21 |
22 |
23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | @code { 37 | private string? message; 38 | private ApplicationUser user = default!; 39 | 40 | [SupplyParameterFromForm] 41 | private InputModel Input { get; set; } = new(); 42 | 43 | [SupplyParameterFromQuery] 44 | private string? ReturnUrl { get; set; } 45 | 46 | protected override async Task OnInitializedAsync() 47 | { 48 | // Ensure the user has gone through the username & password screen first 49 | user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? 50 | throw new InvalidOperationException("Unable to load two-factor authentication user."); 51 | } 52 | 53 | private async Task OnValidSubmitAsync() 54 | { 55 | var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); 56 | 57 | var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); 58 | 59 | var userId = await UserManager.GetUserIdAsync(user); 60 | 61 | if (result.Succeeded) 62 | { 63 | Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId); 64 | RedirectManager.RedirectTo(ReturnUrl); 65 | } 66 | else if (result.IsLockedOut) 67 | { 68 | Logger.LogWarning("User account locked out."); 69 | RedirectManager.RedirectTo("Account/Lockout"); 70 | } 71 | else 72 | { 73 | Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId); 74 | message = "Error: Invalid recovery code entered."; 75 | } 76 | } 77 | 78 | private sealed class InputModel 79 | { 80 | [Required] 81 | [DataType(DataType.Text)] 82 | [Display(Name = "Recovery Code")] 83 | public string RecoveryCode { get; set; } = ""; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /infra/core/gateway/apim.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure API Management instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('The email address of the owner of the service') 7 | @minLength(1) 8 | param publisherEmail string = 'noreply@microsoft.com' 9 | 10 | @description('The name of the owner of the service') 11 | @minLength(1) 12 | param publisherName string = 'n/a' 13 | 14 | @description('The pricing tier of this API Management service') 15 | @allowed([ 16 | 'Consumption' 17 | 'Developer' 18 | 'Standard' 19 | 'Premium' 20 | ]) 21 | param sku string = 'Consumption' 22 | 23 | @description('The instance size of this API Management service.') 24 | @allowed([ 0, 1, 2 ]) 25 | param skuCount int = 0 26 | 27 | @description('Azure Application Insights Name') 28 | param applicationInsightsName string 29 | 30 | resource apimService 'Microsoft.ApiManagement/service@2021-08-01' = { 31 | name: name 32 | location: location 33 | tags: union(tags, { 'azd-service-name': name }) 34 | sku: { 35 | name: sku 36 | capacity: (sku == 'Consumption') ? 0 : ((sku == 'Developer') ? 1 : skuCount) 37 | } 38 | properties: { 39 | publisherEmail: publisherEmail 40 | publisherName: publisherName 41 | // Custom properties are not supported for Consumption SKU 42 | customProperties: sku == 'Consumption' ? {} : { 43 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA': 'false' 44 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA': 'false' 45 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_GCM_SHA256': 'false' 46 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_CBC_SHA256': 'false' 47 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_CBC_SHA256': 'false' 48 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_CBC_SHA': 'false' 49 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_CBC_SHA': 'false' 50 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TripleDes168': 'false' 51 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls10': 'false' 52 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls11': 'false' 53 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Ssl30': 'false' 54 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Tls10': 'false' 55 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Tls11': 'false' 56 | 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Ssl30': 'false' 57 | } 58 | } 59 | } 60 | 61 | resource apimLogger 'Microsoft.ApiManagement/service/loggers@2021-12-01-preview' = if (!empty(applicationInsightsName)) { 62 | name: 'app-insights-logger' 63 | parent: apimService 64 | properties: { 65 | credentials: { 66 | instrumentationKey: applicationInsights.properties.InstrumentationKey 67 | } 68 | description: 'Logger to Azure Application Insights' 69 | isBuffered: false 70 | loggerType: 'applicationInsights' 71 | resourceId: applicationInsights.id 72 | } 73 | } 74 | 75 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { 76 | name: applicationInsightsName 77 | } 78 | 79 | output apimServiceName string = apimService.name 80 | -------------------------------------------------------------------------------- /src/Web/Components/Layout/NavMenu.razor: -------------------------------------------------------------------------------- 1 | @implements IDisposable 2 | 3 | @inject NavigationManager NavigationManager 4 | 5 | 10 | 11 | 12 | 13 | 71 | 72 | @code { 73 | private string? currentUrl; 74 | 75 | protected override void OnInitialized() 76 | { 77 | currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); 78 | NavigationManager.LocationChanged += OnLocationChanged; 79 | } 80 | 81 | private void OnLocationChanged(object? sender, LocationChangedEventArgs e) 82 | { 83 | currentUrl = NavigationManager.ToBaseRelativePath(e.Location); 84 | StateHasChanged(); 85 | } 86 | 87 | public void Dispose() 88 | { 89 | NavigationManager.LocationChanged -= OnLocationChanged; 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/SetPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/SetPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Set password 13 | 14 |

Set your password

15 | 16 |

17 | You do not have a local username/password for this site. Add a local 18 | account so you can log in without an external login. 19 |

20 |
21 |
22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 |
35 | 36 |
37 |
38 |
39 | 40 | @code { 41 | private string? message; 42 | private ApplicationUser user = default!; 43 | 44 | [CascadingParameter] 45 | private HttpContext HttpContext { get; set; } = default!; 46 | 47 | [SupplyParameterFromForm] 48 | private InputModel Input { get; set; } = new(); 49 | 50 | protected override async Task OnInitializedAsync() 51 | { 52 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 53 | 54 | var hasPassword = await UserManager.HasPasswordAsync(user); 55 | if (hasPassword) 56 | { 57 | RedirectManager.RedirectTo("Account/Manage/ChangePassword"); 58 | } 59 | } 60 | 61 | private async Task OnValidSubmitAsync() 62 | { 63 | var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!); 64 | if (!addPasswordResult.Succeeded) 65 | { 66 | message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}"; 67 | return; 68 | } 69 | 70 | await SignInManager.RefreshSignInAsync(user); 71 | RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext); 72 | } 73 | 74 | private sealed class InputModel 75 | { 76 | [Required] 77 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 78 | [DataType(DataType.Password)] 79 | [Display(Name = "New password")] 80 | public string? NewPassword { get; set; } 81 | 82 | [DataType(DataType.Password)] 83 | [Display(Name = "Confirm new password")] 84 | [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] 85 | public string? ConfirmPassword { get; set; } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /infra/core/host/functions.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Function in an existing Azure App Service plan.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | // Reference Properties 7 | param applicationInsightsName string = '' 8 | param appServicePlanId string 9 | param keyVaultName string = '' 10 | param managedIdentity bool = !empty(keyVaultName) || storageManagedIdentity 11 | param storageAccountName string 12 | param storageManagedIdentity bool = false 13 | param virtualNetworkSubnetId string = '' 14 | 15 | // Runtime Properties 16 | @allowed([ 17 | 'dotnet', 'dotnetcore', 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' 18 | ]) 19 | param runtimeName string 20 | param runtimeNameAndVersion string = '${runtimeName}|${runtimeVersion}' 21 | param runtimeVersion string 22 | 23 | // Function Settings 24 | @allowed([ 25 | '~4', '~3', '~2', '~1' 26 | ]) 27 | param extensionVersion string = '~4' 28 | 29 | // Microsoft.Web/sites Properties 30 | param kind string = 'functionapp,linux' 31 | 32 | // Microsoft.Web/sites/config 33 | param allowedOrigins array = [] 34 | param alwaysOn bool = true 35 | param appCommandLine string = '' 36 | @secure() 37 | param appSettings object = {} 38 | param clientAffinityEnabled bool = false 39 | param enableOryxBuild bool = contains(kind, 'linux') 40 | param functionAppScaleLimit int = -1 41 | param linuxFxVersion string = runtimeNameAndVersion 42 | param minimumElasticInstanceCount int = -1 43 | param numberOfWorkers int = -1 44 | param scmDoBuildDuringDeployment bool = true 45 | param use32BitWorkerProcess bool = false 46 | param healthCheckPath string = '' 47 | 48 | module functions 'appservice.bicep' = { 49 | name: '${name}-functions' 50 | params: { 51 | name: name 52 | location: location 53 | tags: tags 54 | allowedOrigins: allowedOrigins 55 | alwaysOn: alwaysOn 56 | appCommandLine: appCommandLine 57 | applicationInsightsName: applicationInsightsName 58 | appServicePlanId: appServicePlanId 59 | appSettings: union(appSettings, { 60 | AzureWebJobsStorage__accountName: storageManagedIdentity ? storage.name : null 61 | AzureWebJobsStorage: storageManagedIdentity ? null : 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' 62 | FUNCTIONS_EXTENSION_VERSION: extensionVersion 63 | FUNCTIONS_WORKER_RUNTIME: runtimeName 64 | }) 65 | clientAffinityEnabled: clientAffinityEnabled 66 | enableOryxBuild: enableOryxBuild 67 | functionAppScaleLimit: functionAppScaleLimit 68 | healthCheckPath: healthCheckPath 69 | keyVaultName: keyVaultName 70 | kind: kind 71 | linuxFxVersion: linuxFxVersion 72 | managedIdentity: managedIdentity 73 | minimumElasticInstanceCount: minimumElasticInstanceCount 74 | numberOfWorkers: numberOfWorkers 75 | runtimeName: runtimeName 76 | runtimeVersion: runtimeVersion 77 | runtimeNameAndVersion: runtimeNameAndVersion 78 | scmDoBuildDuringDeployment: scmDoBuildDuringDeployment 79 | use32BitWorkerProcess: use32BitWorkerProcess 80 | virtualNetworkSubnetId: virtualNetworkSubnetId 81 | } 82 | } 83 | 84 | module storageOwnerRole '../../core/security/role.bicep' = if (storageManagedIdentity) { 85 | name: 'search-index-contrib-role-api' 86 | params: { 87 | principalId: functions.outputs.identityPrincipalId 88 | // Search Index Data Contributor 89 | roleDefinitionId: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' 90 | principalType: 'ServicePrincipal' 91 | } 92 | } 93 | 94 | resource storage 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { 95 | name: storageAccountName 96 | } 97 | 98 | output identityPrincipalId string = managedIdentity ? functions.outputs.identityPrincipalId : '' 99 | output name string = functions.outputs.name 100 | output uri string = functions.outputs.uri 101 | -------------------------------------------------------------------------------- /infra/core/database/sqlserver/sqlserver.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure SQL Server instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param appUser string = 'appUser' 7 | param databaseName string 8 | param keyVaultName string 9 | param sqlAdmin string = 'sqlAdmin' 10 | param connectionStringKey string = 'AZURE-SQL-CONNECTION-STRING' 11 | 12 | @secure() 13 | param sqlAdminPassword string 14 | @secure() 15 | param appUserPassword string 16 | 17 | resource sqlServer 'Microsoft.Sql/servers@2022-05-01-preview' = { 18 | name: name 19 | location: location 20 | tags: tags 21 | properties: { 22 | version: '12.0' 23 | minimalTlsVersion: '1.2' 24 | publicNetworkAccess: 'Enabled' 25 | administratorLogin: sqlAdmin 26 | administratorLoginPassword: sqlAdminPassword 27 | } 28 | 29 | resource database 'databases' = { 30 | name: databaseName 31 | location: location 32 | } 33 | 34 | resource firewall 'firewallRules' = { 35 | name: 'Azure Services' 36 | properties: { 37 | // Allow all clients 38 | // Note: range [0.0.0.0-0.0.0.0] means "allow all Azure-hosted clients only". 39 | // This is not sufficient, because we also want to allow direct access from developer machine, for debugging purposes. 40 | startIpAddress: '0.0.0.1' 41 | endIpAddress: '255.255.255.254' 42 | } 43 | } 44 | } 45 | 46 | resource sqlDeploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { 47 | name: '${name}-deployment-script' 48 | location: location 49 | kind: 'AzureCLI' 50 | properties: { 51 | azCliVersion: '2.37.0' 52 | retentionInterval: 'PT1H' // Retain the script resource for 1 hour after it ends running 53 | timeout: 'PT5M' // Five minutes 54 | cleanupPreference: 'OnSuccess' 55 | environmentVariables: [ 56 | { 57 | name: 'APPUSERNAME' 58 | value: appUser 59 | } 60 | { 61 | name: 'APPUSERPASSWORD' 62 | secureValue: appUserPassword 63 | } 64 | { 65 | name: 'DBNAME' 66 | value: databaseName 67 | } 68 | { 69 | name: 'DBSERVER' 70 | value: sqlServer.properties.fullyQualifiedDomainName 71 | } 72 | { 73 | name: 'SQLCMDPASSWORD' 74 | secureValue: sqlAdminPassword 75 | } 76 | { 77 | name: 'SQLADMIN' 78 | value: sqlAdmin 79 | } 80 | ] 81 | 82 | scriptContent: ''' 83 | wget https://github.com/microsoft/go-sqlcmd/releases/download/v0.8.1/sqlcmd-v0.8.1-linux-x64.tar.bz2 84 | tar x -f sqlcmd-v0.8.1-linux-x64.tar.bz2 -C . 85 | 86 | cat < ./initDb.sql 87 | drop user if exists ${APPUSERNAME} 88 | go 89 | create user ${APPUSERNAME} with password = '${APPUSERPASSWORD}' 90 | go 91 | alter role db_owner add member ${APPUSERNAME} 92 | go 93 | SCRIPT_END 94 | 95 | ./sqlcmd -S ${DBSERVER} -d ${DBNAME} -U ${SQLADMIN} -i ./initDb.sql 96 | ''' 97 | } 98 | } 99 | 100 | resource sqlAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 101 | parent: keyVault 102 | name: 'sqlAdminPassword' 103 | properties: { 104 | value: sqlAdminPassword 105 | } 106 | } 107 | 108 | resource appUserPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 109 | parent: keyVault 110 | name: 'appUserPassword' 111 | properties: { 112 | value: appUserPassword 113 | } 114 | } 115 | 116 | resource sqlAzureConnectionStringSercret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 117 | parent: keyVault 118 | name: connectionStringKey 119 | properties: { 120 | value: '${connectionString}; Password=${appUserPassword}' 121 | } 122 | } 123 | 124 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 125 | name: keyVaultName 126 | } 127 | 128 | var connectionString = 'Server=${sqlServer.properties.fullyQualifiedDomainName}; Database=${sqlServer::database.name}; User=${appUser}' 129 | output connectionStringKey string = connectionStringKey 130 | output databaseName string = sqlServer::database.name 131 | -------------------------------------------------------------------------------- /infra/core/host/container-app-upsert.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates or updates an existing Azure Container App.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('The environment name for the container apps') 7 | param containerAppsEnvironmentName string 8 | 9 | @description('The number of CPU cores allocated to a single container instance, e.g., 0.5') 10 | param containerCpuCoreCount string = '0.5' 11 | 12 | @description('The maximum number of replicas to run. Must be at least 1.') 13 | @minValue(1) 14 | param containerMaxReplicas int = 10 15 | 16 | @description('The amount of memory allocated to a single container instance, e.g., 1Gi') 17 | param containerMemory string = '1.0Gi' 18 | 19 | @description('The minimum number of replicas to run. Must be at least 1.') 20 | @minValue(1) 21 | param containerMinReplicas int = 1 22 | 23 | @description('The name of the container') 24 | param containerName string = 'main' 25 | 26 | @description('The name of the container registry') 27 | param containerRegistryName string = '' 28 | 29 | @description('Hostname suffix for container registry. Set when deploying to sovereign clouds') 30 | param containerRegistryHostSuffix string = 'azurecr.io' 31 | 32 | @allowed([ 'http', 'grpc' ]) 33 | @description('The protocol used by Dapr to connect to the app, e.g., HTTP or gRPC') 34 | param daprAppProtocol string = 'http' 35 | 36 | @description('Enable or disable Dapr for the container app') 37 | param daprEnabled bool = false 38 | 39 | @description('The Dapr app ID') 40 | param daprAppId string = containerName 41 | 42 | @description('Specifies if the resource already exists') 43 | param exists bool = false 44 | 45 | @description('Specifies if Ingress is enabled for the container app') 46 | param ingressEnabled bool = true 47 | 48 | @description('The type of identity for the resource') 49 | @allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) 50 | param identityType string = 'None' 51 | 52 | @description('The name of the user-assigned identity') 53 | param identityName string = '' 54 | 55 | @description('The name of the container image') 56 | param imageName string = '' 57 | 58 | @description('The secrets required for the container') 59 | @secure() 60 | param secrets object = {} 61 | 62 | @description('The environment variables for the container') 63 | param env array = [] 64 | 65 | @description('Specifies if the resource ingress is exposed externally') 66 | param external bool = true 67 | 68 | @description('The service binds associated with the container') 69 | param serviceBinds array = [] 70 | 71 | @description('The target port for the container') 72 | param targetPort int = 80 73 | 74 | resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { 75 | name: name 76 | } 77 | 78 | module app 'container-app.bicep' = { 79 | name: '${deployment().name}-update' 80 | params: { 81 | name: name 82 | location: location 83 | tags: tags 84 | identityType: identityType 85 | identityName: identityName 86 | ingressEnabled: ingressEnabled 87 | containerName: containerName 88 | containerAppsEnvironmentName: containerAppsEnvironmentName 89 | containerRegistryName: containerRegistryName 90 | containerRegistryHostSuffix: containerRegistryHostSuffix 91 | containerCpuCoreCount: containerCpuCoreCount 92 | containerMemory: containerMemory 93 | containerMinReplicas: containerMinReplicas 94 | containerMaxReplicas: containerMaxReplicas 95 | daprEnabled: daprEnabled 96 | daprAppId: daprAppId 97 | daprAppProtocol: daprAppProtocol 98 | secrets: secrets 99 | external: external 100 | env: env 101 | imageName: !empty(imageName) ? imageName : exists ? existingApp.properties.template.containers[0].image : '' 102 | targetPort: targetPort 103 | serviceBinds: serviceBinds 104 | } 105 | } 106 | 107 | output defaultDomain string = app.outputs.defaultDomain 108 | output imageName string = app.outputs.imageName 109 | output name string = app.outputs.name 110 | output uri string = app.outputs.uri 111 | -------------------------------------------------------------------------------- /infra/core/host/ai-environment.bicep: -------------------------------------------------------------------------------- 1 | @minLength(1) 2 | @description('Primary location for all resources') 3 | param location string 4 | 5 | @description('The AI Hub resource name.') 6 | param hubName string 7 | @description('The AI Project resource name.') 8 | param projectName string 9 | @description('The Key Vault resource name.') 10 | param keyVaultName string 11 | @description('The Storage Account resource name.') 12 | param storageAccountName string 13 | @description('The Open AI resource name.') 14 | param openAiName string 15 | @description('The Open AI connection name.') 16 | param openAiConnectionName string 17 | @description('The Open AI model deployments.') 18 | param openAiModelDeployments array = [] 19 | @description('The Log Analytics resource name.') 20 | param logAnalyticsName string = '' 21 | @description('The Application Insights resource name.') 22 | param applicationInsightsName string = '' 23 | @description('The Container Registry resource name.') 24 | param containerRegistryName string = '' 25 | @description('The Azure Search resource name.') 26 | param searchServiceName string = '' 27 | @description('The Azure Search connection name.') 28 | param searchConnectionName string = '' 29 | param tags object = {} 30 | 31 | module hubDependencies '../ai/hub-dependencies.bicep' = { 32 | name: 'hubDependencies' 33 | params: { 34 | location: location 35 | tags: tags 36 | keyVaultName: keyVaultName 37 | storageAccountName: storageAccountName 38 | containerRegistryName: containerRegistryName 39 | applicationInsightsName: applicationInsightsName 40 | logAnalyticsName: logAnalyticsName 41 | openAiName: openAiName 42 | openAiModelDeployments: openAiModelDeployments 43 | searchServiceName: searchServiceName 44 | } 45 | } 46 | 47 | module hub '../ai/hub.bicep' = { 48 | name: 'hub' 49 | params: { 50 | location: location 51 | tags: tags 52 | name: hubName 53 | displayName: hubName 54 | keyVaultId: hubDependencies.outputs.keyVaultId 55 | storageAccountId: hubDependencies.outputs.storageAccountId 56 | containerRegistryId: hubDependencies.outputs.containerRegistryId 57 | applicationInsightsId: hubDependencies.outputs.applicationInsightsId 58 | openAiName: hubDependencies.outputs.openAiName 59 | openAiConnectionName: openAiConnectionName 60 | aiSearchName: hubDependencies.outputs.searchServiceName 61 | aiSearchConnectionName: searchConnectionName 62 | } 63 | } 64 | 65 | module project '../ai/project.bicep' = { 66 | name: 'project' 67 | params: { 68 | location: location 69 | tags: tags 70 | name: projectName 71 | displayName: projectName 72 | hubName: hub.outputs.name 73 | keyVaultName: hubDependencies.outputs.keyVaultName 74 | } 75 | } 76 | 77 | // Outputs 78 | // Resource Group 79 | output resourceGroupName string = resourceGroup().name 80 | 81 | // Hub 82 | output hubName string = hub.outputs.name 83 | output hubPrincipalId string = hub.outputs.principalId 84 | 85 | // Project 86 | output projectName string = project.outputs.name 87 | output projectPrincipalId string = project.outputs.principalId 88 | 89 | // Key Vault 90 | output keyVaultName string = hubDependencies.outputs.keyVaultName 91 | output keyVaultEndpoint string = hubDependencies.outputs.keyVaultEndpoint 92 | 93 | // Application Insights 94 | output applicationInsightsName string = hubDependencies.outputs.applicationInsightsName 95 | output logAnalyticsWorkspaceName string = hubDependencies.outputs.logAnalyticsWorkspaceName 96 | 97 | // Container Registry 98 | output containerRegistryName string = hubDependencies.outputs.containerRegistryName 99 | output containerRegistryEndpoint string = hubDependencies.outputs.containerRegistryEndpoint 100 | 101 | // Storage Account 102 | output storageAccountName string = hubDependencies.outputs.storageAccountName 103 | 104 | // Open AI 105 | output openAiName string = hubDependencies.outputs.openAiName 106 | output openAiEndpoint string = hubDependencies.outputs.openAiEndpoint 107 | 108 | // Search 109 | output searchServiceName string = hubDependencies.outputs.searchServiceName 110 | output searchServiceEndpoint string = hubDependencies.outputs.searchServiceEndpoint 111 | -------------------------------------------------------------------------------- /infra/core/ai/hub.bicep: -------------------------------------------------------------------------------- 1 | @description('The AI Studio Hub Resource name') 2 | param name string 3 | @description('The display name of the AI Studio Hub Resource') 4 | param displayName string = name 5 | @description('The storage account ID to use for the AI Studio Hub Resource') 6 | param storageAccountId string 7 | @description('The key vault ID to use for the AI Studio Hub Resource') 8 | param keyVaultId string 9 | @description('The application insights ID to use for the AI Studio Hub Resource') 10 | param applicationInsightsId string = '' 11 | @description('The container registry ID to use for the AI Studio Hub Resource') 12 | param containerRegistryId string = '' 13 | @description('The OpenAI Cognitive Services account name to use for the AI Studio Hub Resource') 14 | param openAiName string 15 | @description('The OpenAI Cognitive Services account connection name to use for the AI Studio Hub Resource') 16 | param openAiConnectionName string 17 | @description('The Azure Cognitive Search service name to use for the AI Studio Hub Resource') 18 | param aiSearchName string = '' 19 | @description('The Azure Cognitive Search service connection name to use for the AI Studio Hub Resource') 20 | param aiSearchConnectionName string 21 | 22 | @description('The SKU name to use for the AI Studio Hub Resource') 23 | param skuName string = 'Basic' 24 | @description('The SKU tier to use for the AI Studio Hub Resource') 25 | @allowed(['Basic', 'Free', 'Premium', 'Standard']) 26 | param skuTier string = 'Basic' 27 | @description('The public network access setting to use for the AI Studio Hub Resource') 28 | @allowed(['Enabled','Disabled']) 29 | param publicNetworkAccess string = 'Enabled' 30 | 31 | param location string = resourceGroup().location 32 | param tags object = {} 33 | 34 | resource hub 'Microsoft.MachineLearningServices/workspaces@2024-01-01-preview' = { 35 | name: name 36 | location: location 37 | tags: tags 38 | sku: { 39 | name: skuName 40 | tier: skuTier 41 | } 42 | kind: 'Hub' 43 | identity: { 44 | type: 'SystemAssigned' 45 | } 46 | properties: { 47 | friendlyName: displayName 48 | storageAccount: storageAccountId 49 | keyVault: keyVaultId 50 | applicationInsights: !empty(applicationInsightsId) ? applicationInsightsId : null 51 | containerRegistry: !empty(containerRegistryId) ? containerRegistryId : null 52 | hbiWorkspace: false 53 | managedNetwork: { 54 | isolationMode: 'Disabled' 55 | } 56 | v1LegacyMode: false 57 | publicNetworkAccess: publicNetworkAccess 58 | } 59 | 60 | resource contentSafetyDefaultEndpoint 'endpoints' = { 61 | name: 'Azure.ContentSafety' 62 | properties: { 63 | name: 'Azure.ContentSafety' 64 | endpointType: 'Azure.ContentSafety' 65 | associatedResourceId: openAi.id 66 | } 67 | } 68 | 69 | resource openAiConnection 'connections' = { 70 | name: openAiConnectionName 71 | properties: { 72 | category: 'AzureOpenAI' 73 | authType: 'ApiKey' 74 | isSharedToAll: true 75 | target: openAi.properties.endpoints['OpenAI Language Model Instance API'] 76 | metadata: { 77 | ApiVersion: '2023-07-01-preview' 78 | ApiType: 'azure' 79 | ResourceId: openAi.id 80 | } 81 | credentials: { 82 | key: openAi.listKeys().key1 83 | } 84 | } 85 | } 86 | 87 | resource searchConnection 'connections' = 88 | if (!empty(aiSearchName)) { 89 | name: aiSearchConnectionName 90 | properties: { 91 | category: 'CognitiveSearch' 92 | authType: 'ApiKey' 93 | isSharedToAll: true 94 | target: 'https://${search.name}.search.windows.net/' 95 | credentials: { 96 | key: !empty(aiSearchName) ? search.listAdminKeys().primaryKey : '' 97 | } 98 | } 99 | } 100 | } 101 | 102 | resource openAi 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { 103 | name: openAiName 104 | } 105 | 106 | resource search 'Microsoft.Search/searchServices@2021-04-01-preview' existing = 107 | if (!empty(aiSearchName)) { 108 | name: aiSearchName 109 | } 110 | 111 | output name string = hub.name 112 | output id string = hub.id 113 | output principalId string = hub.identity.principalId 114 | -------------------------------------------------------------------------------- /infra/core/host/container-registry.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Registry.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('Indicates whether admin user is enabled') 7 | param adminUserEnabled bool = false 8 | 9 | @description('Indicates whether anonymous pull is enabled') 10 | param anonymousPullEnabled bool = false 11 | 12 | @description('Azure ad authentication as arm policy settings') 13 | param azureADAuthenticationAsArmPolicy object = { 14 | status: 'enabled' 15 | } 16 | 17 | @description('Indicates whether data endpoint is enabled') 18 | param dataEndpointEnabled bool = false 19 | 20 | @description('Encryption settings') 21 | param encryption object = { 22 | status: 'disabled' 23 | } 24 | 25 | @description('Export policy settings') 26 | param exportPolicy object = { 27 | status: 'enabled' 28 | } 29 | 30 | @description('Metadata search settings') 31 | param metadataSearch string = 'Disabled' 32 | 33 | @description('Options for bypassing network rules') 34 | param networkRuleBypassOptions string = 'AzureServices' 35 | 36 | @description('Public network access setting') 37 | param publicNetworkAccess string = 'Enabled' 38 | 39 | @description('Quarantine policy settings') 40 | param quarantinePolicy object = { 41 | status: 'disabled' 42 | } 43 | 44 | @description('Retention policy settings') 45 | param retentionPolicy object = { 46 | days: 7 47 | status: 'disabled' 48 | } 49 | 50 | @description('Scope maps setting') 51 | param scopeMaps array = [] 52 | 53 | @description('SKU settings') 54 | param sku object = { 55 | name: 'Basic' 56 | } 57 | 58 | @description('Soft delete policy settings') 59 | param softDeletePolicy object = { 60 | retentionDays: 7 61 | status: 'disabled' 62 | } 63 | 64 | @description('Trust policy settings') 65 | param trustPolicy object = { 66 | type: 'Notary' 67 | status: 'disabled' 68 | } 69 | 70 | @description('Zone redundancy setting') 71 | param zoneRedundancy string = 'Disabled' 72 | 73 | @description('The log analytics workspace ID used for logging and monitoring') 74 | param workspaceId string = '' 75 | 76 | // 2023-11-01-preview needed for metadataSearch 77 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { 78 | name: name 79 | location: location 80 | tags: tags 81 | sku: sku 82 | properties: { 83 | adminUserEnabled: adminUserEnabled 84 | anonymousPullEnabled: anonymousPullEnabled 85 | dataEndpointEnabled: dataEndpointEnabled 86 | encryption: encryption 87 | metadataSearch: metadataSearch 88 | networkRuleBypassOptions: networkRuleBypassOptions 89 | policies:{ 90 | quarantinePolicy: quarantinePolicy 91 | trustPolicy: trustPolicy 92 | retentionPolicy: retentionPolicy 93 | exportPolicy: exportPolicy 94 | azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy 95 | softDeletePolicy: softDeletePolicy 96 | } 97 | publicNetworkAccess: publicNetworkAccess 98 | zoneRedundancy: zoneRedundancy 99 | } 100 | 101 | resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: { 102 | name: scopeMap.name 103 | properties: scopeMap.properties 104 | }] 105 | } 106 | 107 | // TODO: Update diagnostics to be its own module 108 | // Blocking issue: https://github.com/Azure/bicep/issues/622 109 | // Unable to pass in a `resource` scope or unable to use string interpolation in resource types 110 | resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { 111 | name: 'registry-diagnostics' 112 | scope: containerRegistry 113 | properties: { 114 | workspaceId: workspaceId 115 | logs: [ 116 | { 117 | category: 'ContainerRegistryRepositoryEvents' 118 | enabled: true 119 | } 120 | { 121 | category: 'ContainerRegistryLoginEvents' 122 | enabled: true 123 | } 124 | ] 125 | metrics: [ 126 | { 127 | category: 'AllMetrics' 128 | enabled: true 129 | timeGrain: 'PT1M' 130 | } 131 | ] 132 | } 133 | } 134 | 135 | output id string = containerRegistry.id 136 | output loginServer string = containerRegistry.properties.loginServer 137 | output name string = containerRegistry.name 138 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/TwoFactorAuthentication.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/TwoFactorAuthentication" 2 | 3 | @using Microsoft.AspNetCore.Http.Features 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Two-factor authentication (2FA) 13 | 14 | 15 |

Two-factor authentication (2FA)

16 | @if (canTrack) 17 | { 18 | if (is2faEnabled) 19 | { 20 | if (recoveryCodesLeft == 0) 21 | { 22 |
23 | You have no recovery codes left. 24 |

You must generate a new set of recovery codes before you can log in with a recovery code.

25 |
26 | } 27 | else if (recoveryCodesLeft == 1) 28 | { 29 |
30 | You have 1 recovery code left. 31 |

You can generate a new set of recovery codes.

32 |
33 | } 34 | else if (recoveryCodesLeft <= 3) 35 | { 36 |
37 | You have @recoveryCodesLeft recovery codes left. 38 |

You should generate a new set of recovery codes.

39 |
40 | } 41 | 42 | if (isMachineRemembered) 43 | { 44 |
45 | 46 | 47 | 48 | } 49 | 50 | Disable 2FA 51 | Reset recovery codes 52 | } 53 | 54 |

Authenticator app

55 | @if (!hasAuthenticator) 56 | { 57 | Add authenticator app 58 | } 59 | else 60 | { 61 | Set up authenticator app 62 | Reset authenticator app 63 | } 64 | } 65 | else 66 | { 67 |
68 | Privacy and cookie policy have not been accepted. 69 |

You must accept the policy before you can enable two factor authentication.

70 |
71 | } 72 | 73 | @code { 74 | private bool canTrack; 75 | private bool hasAuthenticator; 76 | private int recoveryCodesLeft; 77 | private bool is2faEnabled; 78 | private bool isMachineRemembered; 79 | 80 | [CascadingParameter] 81 | private HttpContext HttpContext { get; set; } = default!; 82 | 83 | protected override async Task OnInitializedAsync() 84 | { 85 | var user = await UserAccessor.GetRequiredUserAsync(HttpContext); 86 | canTrack = HttpContext.Features.Get()?.CanTrack ?? true; 87 | hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; 88 | is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user); 89 | isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user); 90 | recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user); 91 | } 92 | 93 | private async Task OnSubmitForgetBrowserAsync() 94 | { 95 | await SignInManager.ForgetTwoFactorClientAsync(); 96 | 97 | RedirectManager.RedirectToCurrentPageWithStatus( 98 | "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.", 99 | HttpContext); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/LoginWith2fa.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/LoginWith2fa" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject SignInManager SignInManager 8 | @inject UserManager UserManager 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Two-factor authentication 13 | 14 |

Two-factor authentication

15 |
16 | 17 |

Your login is protected with an authenticator app. Enter your authenticator code below.

18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 | 35 |
36 |
37 | 38 |
39 |
40 |
41 |
42 |

43 | Don't have access to your authenticator device? You can 44 | log in with a recovery code. 45 |

46 | 47 | @code { 48 | private string? message; 49 | private ApplicationUser user = default!; 50 | 51 | [SupplyParameterFromForm] 52 | private InputModel Input { get; set; } = new(); 53 | 54 | [SupplyParameterFromQuery] 55 | private string? ReturnUrl { get; set; } 56 | 57 | [SupplyParameterFromQuery] 58 | private bool RememberMe { get; set; } 59 | 60 | protected override async Task OnInitializedAsync() 61 | { 62 | // Ensure the user has gone through the username & password screen first 63 | user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? 64 | throw new InvalidOperationException("Unable to load two-factor authentication user."); 65 | } 66 | 67 | private async Task OnValidSubmitAsync() 68 | { 69 | var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); 70 | var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine); 71 | var userId = await UserManager.GetUserIdAsync(user); 72 | 73 | if (result.Succeeded) 74 | { 75 | Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); 76 | RedirectManager.RedirectTo(ReturnUrl); 77 | } 78 | else if (result.IsLockedOut) 79 | { 80 | Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); 81 | RedirectManager.RedirectTo("Account/Lockout"); 82 | } 83 | else 84 | { 85 | Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); 86 | message = "Error: Invalid authenticator code."; 87 | } 88 | } 89 | 90 | private sealed class InputModel 91 | { 92 | [Required] 93 | [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 94 | [DataType(DataType.Text)] 95 | [Display(Name = "Authenticator code")] 96 | public string? TwoFactorCode { get; set; } 97 | 98 | [Display(Name = "Remember this machine")] 99 | public bool RememberMachine { get; set; } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/ResetPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResetPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using Microsoft.AspNetCore.Identity 6 | @using Microsoft.AspNetCore.WebUtilities 7 | @using Web.Data 8 | 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject UserManager UserManager 11 | 12 | Reset password 13 | 14 |

Reset password

15 |

Reset your password.

16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 |
40 | 41 |
42 |
43 |
44 | 45 | @code { 46 | private IEnumerable? identityErrors; 47 | 48 | [SupplyParameterFromForm] 49 | private InputModel Input { get; set; } = new(); 50 | 51 | [SupplyParameterFromQuery] 52 | private string? Code { get; set; } 53 | 54 | private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; 55 | 56 | protected override void OnInitialized() 57 | { 58 | if (Code is null) 59 | { 60 | RedirectManager.RedirectTo("Account/InvalidPasswordReset"); 61 | } 62 | 63 | Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 64 | } 65 | 66 | private async Task OnValidSubmitAsync() 67 | { 68 | var user = await UserManager.FindByEmailAsync(Input.Email); 69 | if (user is null) 70 | { 71 | // Don't reveal that the user does not exist 72 | RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); 73 | } 74 | 75 | var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password); 76 | if (result.Succeeded) 77 | { 78 | RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); 79 | } 80 | 81 | identityErrors = result.Errors; 82 | } 83 | 84 | private sealed class InputModel 85 | { 86 | [Required] 87 | [EmailAddress] 88 | public string Email { get; set; } = ""; 89 | 90 | [Required] 91 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 92 | [DataType(DataType.Password)] 93 | public string Password { get; set; } = ""; 94 | 95 | [DataType(DataType.Password)] 96 | [Display(Name = "Confirm password")] 97 | [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] 98 | public string ConfirmPassword { get; set; } = ""; 99 | 100 | [Required] 101 | public string Code { get; set; } = ""; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/ChangePassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/ChangePassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using Web.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | @inject ILogger Logger 12 | 13 | Change password 14 | 15 |

Change password

16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 |
37 | 38 |
39 |
40 |
41 | 42 | @code { 43 | private string? message; 44 | private ApplicationUser user = default!; 45 | private bool hasPassword; 46 | 47 | [CascadingParameter] 48 | private HttpContext HttpContext { get; set; } = default!; 49 | 50 | [SupplyParameterFromForm] 51 | private InputModel Input { get; set; } = new(); 52 | 53 | protected override async Task OnInitializedAsync() 54 | { 55 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 56 | hasPassword = await UserManager.HasPasswordAsync(user); 57 | if (!hasPassword) 58 | { 59 | RedirectManager.RedirectTo("Account/Manage/SetPassword"); 60 | } 61 | } 62 | 63 | private async Task OnValidSubmitAsync() 64 | { 65 | var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); 66 | if (!changePasswordResult.Succeeded) 67 | { 68 | message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}"; 69 | return; 70 | } 71 | 72 | await SignInManager.RefreshSignInAsync(user); 73 | Logger.LogInformation("User changed their password successfully."); 74 | 75 | RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext); 76 | } 77 | 78 | private sealed class InputModel 79 | { 80 | [Required] 81 | [DataType(DataType.Password)] 82 | [Display(Name = "Current password")] 83 | public string OldPassword { get; set; } = ""; 84 | 85 | [Required] 86 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 87 | [DataType(DataType.Password)] 88 | [Display(Name = "New password")] 89 | public string NewPassword { get; set; } = ""; 90 | 91 | [DataType(DataType.Password)] 92 | [Display(Name = "Confirm new password")] 93 | [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] 94 | public string ConfirmPassword { get; set; } = ""; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /infra/core/host/aks-managed-cluster.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Kubernetes Service (AKS) cluster with a system agent pool.' 2 | @description('The name for the AKS managed cluster') 3 | param name string 4 | 5 | @description('The name of the resource group for the managed resources of the AKS cluster') 6 | param nodeResourceGroupName string = '' 7 | 8 | @description('The Azure region/location for the AKS resources') 9 | param location string = resourceGroup().location 10 | 11 | @description('Custom tags to apply to the AKS resources') 12 | param tags object = {} 13 | 14 | @description('Kubernetes Version') 15 | param kubernetesVersion string = '1.29' 16 | 17 | @description('Whether RBAC is enabled for local accounts') 18 | param enableRbac bool = true 19 | 20 | // Add-ons 21 | @description('Whether web app routing (preview) add-on is enabled') 22 | param webAppRoutingAddon bool = true 23 | 24 | // AAD Integration 25 | @description('Enable Azure Active Directory integration') 26 | param enableAad bool = false 27 | 28 | @description('Enable RBAC using AAD') 29 | param enableAzureRbac bool = false 30 | 31 | @description('The Tenant ID associated to the Azure Active Directory') 32 | param aadTenantId string = tenant().tenantId 33 | 34 | @description('The load balancer SKU to use for ingress into the AKS cluster') 35 | @allowed([ 'basic', 'standard' ]) 36 | param loadBalancerSku string = 'standard' 37 | 38 | @description('Network plugin used for building the Kubernetes network.') 39 | @allowed([ 'azure', 'kubenet', 'none' ]) 40 | param networkPlugin string = 'azure' 41 | 42 | @description('Network policy used for building the Kubernetes network.') 43 | @allowed([ 'azure', 'calico' ]) 44 | param networkPolicy string = 'azure' 45 | 46 | @description('If set to true, getting static credentials will be disabled for this cluster.') 47 | param disableLocalAccounts bool = false 48 | 49 | @description('The managed cluster SKU.') 50 | @allowed([ 'Free', 'Paid', 'Standard' ]) 51 | param sku string = 'Free' 52 | 53 | @description('Configuration of AKS add-ons') 54 | param addOns object = {} 55 | 56 | @description('The log analytics workspace id used for logging & monitoring') 57 | param workspaceId string = '' 58 | 59 | @description('The node pool configuration for the System agent pool') 60 | param systemPoolConfig object 61 | 62 | @description('The DNS prefix to associate with the AKS cluster') 63 | param dnsPrefix string = '' 64 | 65 | resource aks 'Microsoft.ContainerService/managedClusters@2023-10-02-preview' = { 66 | name: name 67 | location: location 68 | tags: tags 69 | identity: { 70 | type: 'SystemAssigned' 71 | } 72 | sku: { 73 | name: 'Base' 74 | tier: sku 75 | } 76 | properties: { 77 | nodeResourceGroup: !empty(nodeResourceGroupName) ? nodeResourceGroupName : 'rg-mc-${name}' 78 | kubernetesVersion: kubernetesVersion 79 | dnsPrefix: empty(dnsPrefix) ? '${name}-dns' : dnsPrefix 80 | enableRBAC: enableRbac 81 | aadProfile: enableAad ? { 82 | managed: true 83 | enableAzureRBAC: enableAzureRbac 84 | tenantID: aadTenantId 85 | } : null 86 | agentPoolProfiles: [ 87 | systemPoolConfig 88 | ] 89 | networkProfile: { 90 | loadBalancerSku: loadBalancerSku 91 | networkPlugin: networkPlugin 92 | networkPolicy: networkPolicy 93 | } 94 | disableLocalAccounts: disableLocalAccounts && enableAad 95 | addonProfiles: addOns 96 | ingressProfile: { 97 | webAppRouting: { 98 | enabled: webAppRoutingAddon 99 | } 100 | } 101 | } 102 | } 103 | 104 | var aksDiagCategories = [ 105 | 'cluster-autoscaler' 106 | 'kube-controller-manager' 107 | 'kube-audit-admin' 108 | 'guard' 109 | ] 110 | 111 | // TODO: Update diagnostics to be its own module 112 | // Blocking issue: https://github.com/Azure/bicep/issues/622 113 | // Unable to pass in a `resource` scope or unable to use string interpolation in resource types 114 | resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { 115 | name: 'aks-diagnostics' 116 | scope: aks 117 | properties: { 118 | workspaceId: workspaceId 119 | logs: [for category in aksDiagCategories: { 120 | category: category 121 | enabled: true 122 | }] 123 | metrics: [ 124 | { 125 | category: 'AllMetrics' 126 | enabled: true 127 | } 128 | ] 129 | } 130 | } 131 | 132 | @description('The resource name of the AKS cluster') 133 | output clusterName string = aks.name 134 | 135 | @description('The AKS cluster identity') 136 | output clusterIdentity object = { 137 | clientId: aks.properties.identityProfile.kubeletidentity.clientId 138 | objectId: aks.properties.identityProfile.kubeletidentity.objectId 139 | resourceId: aks.properties.identityProfile.kubeletidentity.resourceId 140 | } 141 | -------------------------------------------------------------------------------- /infra/core/host/appservice.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure App Service in an existing Azure App Service plan.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | // Reference Properties 7 | param applicationInsightsName string = '' 8 | param appServicePlanId string 9 | param keyVaultName string = '' 10 | param managedIdentity bool = !empty(keyVaultName) 11 | 12 | // Runtime Properties 13 | @allowed([ 14 | 'dotnet', 'dotnetcore', 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' 15 | ]) 16 | param runtimeName string 17 | param runtimeNameAndVersion string = '${runtimeName}|${runtimeVersion}' 18 | param runtimeVersion string 19 | 20 | // Microsoft.Web/sites Properties 21 | param kind string = 'app,linux' 22 | 23 | // Microsoft.Web/sites/config 24 | param allowedOrigins array = [] 25 | param alwaysOn bool = true 26 | param appCommandLine string = '' 27 | @secure() 28 | param appSettings object = {} 29 | param clientAffinityEnabled bool = false 30 | param enableOryxBuild bool = contains(kind, 'linux') 31 | param functionAppScaleLimit int = -1 32 | param linuxFxVersion string = runtimeNameAndVersion 33 | param minimumElasticInstanceCount int = -1 34 | param numberOfWorkers int = -1 35 | param scmDoBuildDuringDeployment bool = false 36 | param use32BitWorkerProcess bool = false 37 | param ftpsState string = 'FtpsOnly' 38 | param healthCheckPath string = '' 39 | param virtualNetworkSubnetId string = '' 40 | 41 | resource appService 'Microsoft.Web/sites@2022-03-01' = { 42 | name: name 43 | location: location 44 | tags: tags 45 | kind: kind 46 | properties: { 47 | serverFarmId: appServicePlanId 48 | siteConfig: { 49 | linuxFxVersion: linuxFxVersion 50 | alwaysOn: alwaysOn 51 | ftpsState: ftpsState 52 | minTlsVersion: '1.2' 53 | appCommandLine: appCommandLine 54 | numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null 55 | minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null 56 | use32BitWorkerProcess: use32BitWorkerProcess 57 | functionAppScaleLimit: functionAppScaleLimit != -1 ? functionAppScaleLimit : null 58 | healthCheckPath: healthCheckPath 59 | cors: { 60 | allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) 61 | } 62 | } 63 | clientAffinityEnabled: clientAffinityEnabled 64 | httpsOnly: true 65 | virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null 66 | } 67 | 68 | identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } 69 | 70 | resource basicPublishingCredentialsPoliciesFtp 'basicPublishingCredentialsPolicies' = { 71 | name: 'ftp' 72 | properties: { 73 | allow: false 74 | } 75 | } 76 | 77 | resource basicPublishingCredentialsPoliciesScm 'basicPublishingCredentialsPolicies' = { 78 | name: 'scm' 79 | properties: { 80 | allow: false 81 | } 82 | } 83 | } 84 | 85 | // Updates to the single Microsoft.sites/web/config resources that need to be performed sequentially 86 | // sites/web/config 'appsettings' 87 | module configAppSettings 'appservice-appsettings.bicep' = { 88 | name: '${name}-appSettings' 89 | params: { 90 | name: appService.name 91 | appSettings: union(appSettings, 92 | { 93 | SCM_DO_BUILD_DURING_DEPLOYMENT: string(scmDoBuildDuringDeployment) 94 | ENABLE_ORYX_BUILD: string(enableOryxBuild) 95 | }, 96 | runtimeName == 'python' && appCommandLine == '' ? { PYTHON_ENABLE_GUNICORN_MULTIWORKERS: 'true'} : {}, 97 | !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, 98 | !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) 99 | } 100 | } 101 | 102 | // sites/web/config 'logs' 103 | resource configLogs 'Microsoft.Web/sites/config@2022-03-01' = { 104 | name: 'logs' 105 | parent: appService 106 | properties: { 107 | applicationLogs: { fileSystem: { level: 'Verbose' } } 108 | detailedErrorMessages: { enabled: true } 109 | failedRequestsTracing: { enabled: true } 110 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 111 | } 112 | dependsOn: [configAppSettings] 113 | } 114 | 115 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) { 116 | name: keyVaultName 117 | } 118 | 119 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { 120 | name: applicationInsightsName 121 | } 122 | 123 | output identityPrincipalId string = managedIdentity ? appService.identity.principalId : '' 124 | output name string = appService.name 125 | output uri string = 'https://${appService.properties.defaultHostName}' 126 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | // The main bicep module to provision Azure resources. 4 | // For a more complete walkthrough to understand how this file works with azd, 5 | // see https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create 6 | 7 | @minLength(1) 8 | @maxLength(64) 9 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 10 | param environmentName string 11 | 12 | @minLength(1) 13 | @description('Primary location for all resources') 14 | param location string 15 | 16 | @description('Id of the user or app to assign application roles') 17 | param principalId string = '' 18 | 19 | @secure() 20 | @description('SQL Server administrator password') 21 | param sqlAdminPassword string 22 | 23 | @secure() 24 | @description('Application user password') 25 | param appUserPassword string 26 | 27 | // Optional parameters to override the default azd resource naming conventions. 28 | // Add the following to main.parameters.json to provide values: 29 | // "resourceGroupName": { 30 | // "value": "myGroupName" 31 | // } 32 | param resourceGroupName string = '' 33 | 34 | var abbrs = loadJsonContent('./abbreviations.json') 35 | 36 | // tags that should be applied to all resources. 37 | var tags = { 38 | // Tag all resources with the environment name. 39 | 'azd-env-name': environmentName 40 | } 41 | 42 | // Generate a unique token to be used in naming resources. 43 | // Remove linter suppression after using. 44 | #disable-next-line no-unused-vars 45 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 46 | 47 | // Name of the service defined in azure.yaml 48 | var webServiceName = 'web' 49 | 50 | // Organize resources in a resource group 51 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 52 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 53 | location: location 54 | tags: tags 55 | } 56 | 57 | // Add resources to be provisioned below. 58 | // A full example that leverages azd bicep modules can be seen in the todo-python-mongo template: 59 | // https://github.com/Azure-Samples/todo-python-mongo/tree/main/infra 60 | 61 | // Provision resources to support the web app 62 | module web './app/web.bicep' = { 63 | name: '${deployment().name}-app' 64 | scope: rg 65 | params: { 66 | location: location 67 | tags: tags 68 | webServiceName: webServiceName 69 | logAnalyticsName: '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 70 | applicationInsightsName: '${abbrs.insightsComponents}${resourceToken}' 71 | applicationInsightsDashboardName: '${abbrs.portalDashboards}${resourceToken}' 72 | appServicePlanName: '${abbrs.webServerFarms}${resourceToken}' 73 | appServiceName: '${abbrs.webSitesAppService}${resourceToken}' 74 | keyVaultName: keyVault.outputs.name 75 | } 76 | } 77 | 78 | // Provision key vault 79 | module keyVault './core/security/keyvault.bicep' = { 80 | name: 'keyvault' 81 | scope: rg 82 | params: { 83 | name: '${abbrs.keyVaultVaults}${resourceToken}' 84 | location: location 85 | tags: tags 86 | principalId: principalId 87 | } 88 | } 89 | 90 | // Create an Azure SQL Server instance and database and add key vault secrets 91 | module database './core/database/sqlserver/sqlserver.bicep' = { 92 | name: 'database' 93 | scope: rg 94 | params: { 95 | name: '${abbrs.sqlServers}${resourceToken}' 96 | location: location 97 | tags: tags 98 | databaseName: '${resourceToken}-sql-database' 99 | keyVaultName: keyVault.outputs.name 100 | connectionStringKey: 'ConnectionStrings--DefaultConnection' 101 | sqlAdminPassword: sqlAdminPassword 102 | appUserPassword: appUserPassword 103 | } 104 | } 105 | 106 | // Create an access policy for the web service to access the key vault secrets 107 | module webKeyVaultAccess './core/security/keyvault-access.bicep' = { 108 | name: 'web-keyvault-access' 109 | scope: rg 110 | params: { 111 | keyVaultName: keyVault.outputs.name 112 | principalId: web.outputs.SERVICE_WEB_IDENTITY_PRINCIPAL_ID 113 | } 114 | } 115 | 116 | // Add outputs from the deployment here, if needed. 117 | // 118 | // This allows the outputs to be referenced by other bicep deployments in the deployment pipeline, 119 | // or by the local machine as a way to reference created resources in Azure for local development. 120 | // Secrets should not be added here. 121 | // 122 | // Outputs are automatically saved in the local azd environment .env file. 123 | // To see these outputs, run `azd env get-values`, or `azd env get-values --output json` for json output. 124 | output AZURE_LOCATION string = location 125 | output AZURE_TENANT_ID string = tenant().tenantId 126 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 127 | output SERVICE_WEB_URI string = web.outputs.SERVICE_WEB_URI 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blazor Web App with C# and SQL Database on Azure 2 | 3 | A starter project for creating a Blazor web app using C# and a SQL database hosted on Azure. The project contains sample application code which can be removed and replaced with your own application code. Add your own source code and leverage the Infrastructure as Code assets (written in Bicep) to get the app up and running quickly. 4 | 5 | ## Prerequisites 6 | 7 | > This template will create infrastructure and deploy code to Azure. If you don't have an Azure Subscription, you can sign up for a [free account here](https://azure.microsoft.com/free/). Make sure you have contributor role to the Azure subscription. 8 | 9 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally. 10 | 11 | - [Azure Developer CLI](https://aka.ms/azd-install) 12 | - [.NET SDK 9.0](https://dotnet.microsoft.com/download/dotnet/9.0) 13 | 14 | ## Quickstart 15 | To learn how to get started with any AZD template, follow the steps in [this quickstart](https://learn.microsoft.com/azure/developer/azure-developer-cli/get-started?tabs=localinstall&pivots=programming-language-csharp) with this template (`jasontaylordev/azd-blazor`). 16 | 17 | This quickstart will show you how to authenticate on Azure, initialize using a template, provision infrastructure and deploy code on Azure via the following commands: 18 | 19 | ```bash 20 | # Log in to azd. 21 | azd auth login 22 | 23 | # First-time project setup. Initialize a project in the current directory, using this template. 24 | azd init --template jasontaylordev/azd-blazor 25 | 26 | # Provision and deploy to Azure 27 | azd up 28 | ``` 29 | 30 | ## Application Architecture 31 | 32 | This application utilizes the following Azure resources: 33 | 34 | - [**Azure App Service**](https://docs.microsoft.com/azure/app-service/) to host the web app 35 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 36 | - [**Azure SQL Database**](https://docs.microsoft.com/azure/azure-sql/database/sql-database-paas-overview?view=azuresql) for storage 37 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 38 | 39 | Here's a high level architecture diagram that illustrates these components. Notice that these are all contained within a single [resource group](https://docs.microsoft.com/azure/azure-resource-manager/management/manage-resource-groups-portal), that will be created for you when you create the resources. 40 | 41 | !["Application architecture diagram"](assets/architecture-diagram.png) 42 | 43 | 44 | 45 | ## Cost of provisioning and deploying this template 46 | This template provisions resources to an Azure subscription that you will select upon provisioning them. Refer to the [Pricing calculator for Microsoft Azure](https://azure.microsoft.com/pricing/calculator/) to estimate the cost you might incur when this template is running on Azure and, if needed, update the included Azure resource definitions found in `infra/main.bicep` to suit your needs. 47 | 48 | ## Application Code 49 | 50 | This template is structured to follow the [Azure Developer CLI](https://aka.ms/azure-dev/overview). You can learn more about `azd` architecture in [the official documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create#understand-the-azd-architecture). 51 | 52 | ## Next Steps 53 | 54 | At this point, you have a complete application deployed on Azure. But there is much more that the Azure Developer CLI can do. These next steps will introduce you to additional commands that will make creating applications on Azure much easier. Using the Azure Developer CLI, you can setup your pipelines, monitor your application, test and debug locally. 55 | 56 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 57 | 58 | - [`azd pipeline config`](https://learn.microsoft.com/azure/developer/azure-developer-cli/configure-devops-pipeline?tabs=GitHub) - to configure a CI/CD pipeline (using GitHub Actions or Azure DevOps) to deploy your application whenever code is pushed to the main branch. 59 | 60 | - [`azd monitor`](https://learn.microsoft.com/azure/developer/azure-developer-cli/monitor-your-app) - to monitor the application and quickly navigate to the various Application Insights dashboards (e.g. overview, live metrics, logs) 61 | 62 | - [Run and Debug Locally](https://learn.microsoft.com/azure/developer/azure-developer-cli/debug?pivots=ide-vs-code) - using Visual Studio Code and the Azure Developer CLI extension 63 | 64 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 65 | 66 | ## Additional `azd` commands 67 | 68 | The Azure Developer CLI includes many other commands to help with your Azure development experience. You can view these commands at the terminal by running `azd help`. You can also view the full list of commands on our [Azure Developer CLI command](https://aka.ms/azure-dev/ref) page. 69 | 70 | ## Reporting Issues and Feedback 71 | 72 | If you have any feature requests, issues, or areas for improvement, please [file an issue](https://github.com/jasontaylordev/azd-blazor/issues). -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Login.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Login" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Authentication 5 | @using Microsoft.AspNetCore.Identity 6 | @using Web.Data 7 | 8 | @inject SignInManager SignInManager 9 | @inject ILogger Logger 10 | @inject NavigationManager NavigationManager 11 | @inject IdentityRedirectManager RedirectManager 12 | 13 | Log in 14 | 15 |

Log in

16 |
17 |
18 |
19 | 20 | 21 | 22 |

Use a local account to log in.

23 |
24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 |
35 |
36 | 40 |
41 |
42 | 43 |
44 | 55 |
56 |
57 |
58 |
59 |
60 |

Use another service to log in.

61 |
62 | 63 |
64 |
65 |
66 | 67 | @code { 68 | private string? errorMessage; 69 | 70 | [CascadingParameter] 71 | private HttpContext HttpContext { get; set; } = default!; 72 | 73 | [SupplyParameterFromForm] 74 | private InputModel Input { get; set; } = new(); 75 | 76 | [SupplyParameterFromQuery] 77 | private string? ReturnUrl { get; set; } 78 | 79 | protected override async Task OnInitializedAsync() 80 | { 81 | if (HttpMethods.IsGet(HttpContext.Request.Method)) 82 | { 83 | // Clear the existing external cookie to ensure a clean login process 84 | await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); 85 | } 86 | } 87 | 88 | public async Task LoginUser() 89 | { 90 | // This doesn't count login failures towards account lockout 91 | // To enable password failures to trigger account lockout, set lockoutOnFailure: true 92 | var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); 93 | if (result.Succeeded) 94 | { 95 | Logger.LogInformation("User logged in."); 96 | RedirectManager.RedirectTo(ReturnUrl); 97 | } 98 | else if (result.RequiresTwoFactor) 99 | { 100 | RedirectManager.RedirectTo( 101 | "Account/LoginWith2fa", 102 | new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe }); 103 | } 104 | else if (result.IsLockedOut) 105 | { 106 | Logger.LogWarning("User account locked out."); 107 | RedirectManager.RedirectTo("Account/Lockout"); 108 | } 109 | else 110 | { 111 | errorMessage = "Error: Invalid login attempt."; 112 | } 113 | } 114 | 115 | private sealed class InputModel 116 | { 117 | [Required] 118 | [EmailAddress] 119 | public string Email { get; set; } = ""; 120 | 121 | [Required] 122 | [DataType(DataType.Password)] 123 | public string Password { get; set; } = ""; 124 | 125 | [Display(Name = "Remember me?")] 126 | public bool RememberMe { get; set; } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Web/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using System.Text.Json; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.AspNetCore.Components.Authorization; 5 | using Microsoft.AspNetCore.Http.Extensions; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.Extensions.Primitives; 9 | using Web.Components.Account.Pages; 10 | using Web.Components.Account.Pages.Manage; 11 | using Web.Data; 12 | 13 | namespace Microsoft.AspNetCore.Routing; 14 | 15 | internal static class IdentityComponentsEndpointRouteBuilderExtensions 16 | { 17 | // These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project. 18 | public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints) 19 | { 20 | ArgumentNullException.ThrowIfNull(endpoints); 21 | 22 | var accountGroup = endpoints.MapGroup("/Account"); 23 | 24 | accountGroup.MapPost("/PerformExternalLogin", ( 25 | HttpContext context, 26 | [FromServices] SignInManager signInManager, 27 | [FromForm] string provider, 28 | [FromForm] string returnUrl) => 29 | { 30 | IEnumerable> query = [ 31 | new("ReturnUrl", returnUrl), 32 | new("Action", ExternalLogin.LoginCallbackAction)]; 33 | 34 | var redirectUrl = UriHelper.BuildRelative( 35 | context.Request.PathBase, 36 | "/Account/ExternalLogin", 37 | QueryString.Create(query)); 38 | 39 | var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); 40 | return TypedResults.Challenge(properties, [provider]); 41 | }); 42 | 43 | accountGroup.MapPost("/Logout", async ( 44 | ClaimsPrincipal user, 45 | SignInManager signInManager, 46 | [FromForm] string returnUrl) => 47 | { 48 | await signInManager.SignOutAsync(); 49 | return TypedResults.LocalRedirect($"~/{returnUrl}"); 50 | }); 51 | 52 | var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); 53 | 54 | manageGroup.MapPost("/LinkExternalLogin", async ( 55 | HttpContext context, 56 | [FromServices] SignInManager signInManager, 57 | [FromForm] string provider) => 58 | { 59 | // Clear the existing external cookie to ensure a clean login process 60 | await context.SignOutAsync(IdentityConstants.ExternalScheme); 61 | 62 | var redirectUrl = UriHelper.BuildRelative( 63 | context.Request.PathBase, 64 | "/Account/Manage/ExternalLogins", 65 | QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction)); 66 | 67 | var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User)); 68 | return TypedResults.Challenge(properties, [provider]); 69 | }); 70 | 71 | var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); 72 | var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); 73 | 74 | manageGroup.MapPost("/DownloadPersonalData", async ( 75 | HttpContext context, 76 | [FromServices] UserManager userManager, 77 | [FromServices] AuthenticationStateProvider authenticationStateProvider) => 78 | { 79 | var user = await userManager.GetUserAsync(context.User); 80 | if (user is null) 81 | { 82 | return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'."); 83 | } 84 | 85 | var userId = await userManager.GetUserIdAsync(user); 86 | downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId); 87 | 88 | // Only include personal data for download 89 | var personalData = new Dictionary(); 90 | var personalDataProps = typeof(ApplicationUser).GetProperties().Where( 91 | prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); 92 | foreach (var p in personalDataProps) 93 | { 94 | personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); 95 | } 96 | 97 | var logins = await userManager.GetLoginsAsync(user); 98 | foreach (var l in logins) 99 | { 100 | personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey); 101 | } 102 | 103 | personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!); 104 | var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData); 105 | 106 | context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json"); 107 | return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json"); 108 | }); 109 | 110 | return accountGroup; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Web/Components/Account/Pages/Manage/Email.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/Email" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | @using Web.Data 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject IdentityUserAccessor UserAccessor 13 | @inject NavigationManager NavigationManager 14 | 15 | Manage email 16 | 17 |

Manage email

18 | 19 | 20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 | @if (isEmailConfirmed) 29 | { 30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 | } 38 | else 39 | { 40 |
41 | 42 | 43 | 44 |
45 | } 46 |
47 | 48 | 49 | 50 |
51 | 52 |
53 |
54 |
55 | 56 | @code { 57 | private string? message; 58 | private ApplicationUser user = default!; 59 | private string? email; 60 | private bool isEmailConfirmed; 61 | 62 | [CascadingParameter] 63 | private HttpContext HttpContext { get; set; } = default!; 64 | 65 | [SupplyParameterFromForm(FormName = "change-email")] 66 | private InputModel Input { get; set; } = new(); 67 | 68 | protected override async Task OnInitializedAsync() 69 | { 70 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 71 | email = await UserManager.GetEmailAsync(user); 72 | isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user); 73 | 74 | Input.NewEmail ??= email; 75 | } 76 | 77 | private async Task OnValidSubmitAsync() 78 | { 79 | if (Input.NewEmail is null || Input.NewEmail == email) 80 | { 81 | message = "Your email is unchanged."; 82 | return; 83 | } 84 | 85 | var userId = await UserManager.GetUserIdAsync(user); 86 | var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail); 87 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 88 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 89 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri, 90 | new Dictionary { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code }); 91 | 92 | await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); 93 | 94 | message = "Confirmation link to change email sent. Please check your email."; 95 | } 96 | 97 | private async Task OnSendEmailVerificationAsync() 98 | { 99 | if (email is null) 100 | { 101 | return; 102 | } 103 | 104 | var userId = await UserManager.GetUserIdAsync(user); 105 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 106 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 107 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 108 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 109 | new Dictionary { ["userId"] = userId, ["code"] = code }); 110 | 111 | await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl)); 112 | 113 | message = "Verification email sent. Please check your email."; 114 | } 115 | 116 | private sealed class InputModel 117 | { 118 | [Required] 119 | [EmailAddress] 120 | [Display(Name = "New email")] 121 | public string? NewEmail { get; set; } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /infra/core/ai/hub-dependencies.bicep: -------------------------------------------------------------------------------- 1 | param location string = resourceGroup().location 2 | param tags object = {} 3 | 4 | @description('Name of the key vault') 5 | param keyVaultName string 6 | @description('Name of the storage account') 7 | param storageAccountName string 8 | @description('Name of the OpenAI cognitive services') 9 | param openAiName string 10 | @description('Array of OpenAI model deployments') 11 | param openAiModelDeployments array = [] 12 | @description('Name of the Log Analytics workspace') 13 | param logAnalyticsName string = '' 14 | @description('Name of the Application Insights instance') 15 | param applicationInsightsName string = '' 16 | @description('Name of the container registry') 17 | param containerRegistryName string = '' 18 | @description('Name of the Azure Cognitive Search service') 19 | param searchServiceName string = '' 20 | 21 | module keyVault '../security/keyvault.bicep' = { 22 | name: 'keyvault' 23 | params: { 24 | location: location 25 | tags: tags 26 | name: keyVaultName 27 | } 28 | } 29 | 30 | module storageAccount '../storage/storage-account.bicep' = { 31 | name: 'storageAccount' 32 | params: { 33 | location: location 34 | tags: tags 35 | name: storageAccountName 36 | containers: [ 37 | { 38 | name: 'default' 39 | } 40 | ] 41 | files: [ 42 | { 43 | name: 'default' 44 | } 45 | ] 46 | queues: [ 47 | { 48 | name: 'default' 49 | } 50 | ] 51 | tables: [ 52 | { 53 | name: 'default' 54 | } 55 | ] 56 | corsRules: [ 57 | { 58 | allowedOrigins: [ 59 | 'https://mlworkspace.azure.ai' 60 | 'https://ml.azure.com' 61 | 'https://*.ml.azure.com' 62 | 'https://ai.azure.com' 63 | 'https://*.ai.azure.com' 64 | 'https://mlworkspacecanary.azure.ai' 65 | 'https://mlworkspace.azureml-test.net' 66 | ] 67 | allowedMethods: [ 68 | 'GET' 69 | 'HEAD' 70 | 'POST' 71 | 'PUT' 72 | 'DELETE' 73 | 'OPTIONS' 74 | 'PATCH' 75 | ] 76 | maxAgeInSeconds: 1800 77 | exposedHeaders: [ 78 | '*' 79 | ] 80 | allowedHeaders: [ 81 | '*' 82 | ] 83 | } 84 | ] 85 | deleteRetentionPolicy: { 86 | allowPermanentDelete: false 87 | enabled: false 88 | } 89 | shareDeleteRetentionPolicy: { 90 | enabled: true 91 | days: 7 92 | } 93 | } 94 | } 95 | 96 | module logAnalytics '../monitor/loganalytics.bicep' = 97 | if (!empty(logAnalyticsName)) { 98 | name: 'logAnalytics' 99 | params: { 100 | location: location 101 | tags: tags 102 | name: logAnalyticsName 103 | } 104 | } 105 | 106 | module applicationInsights '../monitor/applicationinsights.bicep' = 107 | if (!empty(applicationInsightsName) && !empty(logAnalyticsName)) { 108 | name: 'applicationInsights' 109 | params: { 110 | location: location 111 | tags: tags 112 | name: applicationInsightsName 113 | logAnalyticsWorkspaceId: !empty(logAnalyticsName) ? logAnalytics.outputs.id : '' 114 | } 115 | } 116 | 117 | module containerRegistry '../host/container-registry.bicep' = 118 | if (!empty(containerRegistryName)) { 119 | name: 'containerRegistry' 120 | params: { 121 | location: location 122 | tags: tags 123 | name: containerRegistryName 124 | } 125 | } 126 | 127 | module cognitiveServices '../ai/cognitiveservices.bicep' = { 128 | name: 'cognitiveServices' 129 | params: { 130 | location: location 131 | tags: tags 132 | name: openAiName 133 | kind: 'AIServices' 134 | deployments: openAiModelDeployments 135 | } 136 | } 137 | 138 | module searchService '../search/search-services.bicep' = 139 | if (!empty(searchServiceName)) { 140 | name: 'searchService' 141 | params: { 142 | location: location 143 | tags: tags 144 | name: searchServiceName 145 | } 146 | } 147 | 148 | output keyVaultId string = keyVault.outputs.id 149 | output keyVaultName string = keyVault.outputs.name 150 | output keyVaultEndpoint string = keyVault.outputs.endpoint 151 | 152 | output storageAccountId string = storageAccount.outputs.id 153 | output storageAccountName string = storageAccount.outputs.name 154 | 155 | output containerRegistryId string = !empty(containerRegistryName) ? containerRegistry.outputs.id : '' 156 | output containerRegistryName string = !empty(containerRegistryName) ? containerRegistry.outputs.name : '' 157 | output containerRegistryEndpoint string = !empty(containerRegistryName) ? containerRegistry.outputs.loginServer : '' 158 | 159 | output applicationInsightsId string = !empty(applicationInsightsName) ? applicationInsights.outputs.id : '' 160 | output applicationInsightsName string = !empty(applicationInsightsName) ? applicationInsights.outputs.name : '' 161 | output logAnalyticsWorkspaceId string = !empty(logAnalyticsName) ? logAnalytics.outputs.id : '' 162 | output logAnalyticsWorkspaceName string = !empty(logAnalyticsName) ? logAnalytics.outputs.name : '' 163 | 164 | output openAiId string = cognitiveServices.outputs.id 165 | output openAiName string = cognitiveServices.outputs.name 166 | output openAiEndpoint string = cognitiveServices.outputs.endpoints['OpenAI Language Model Instance API'] 167 | 168 | output searchServiceId string = !empty(searchServiceName) ? searchService.outputs.id : '' 169 | output searchServiceName string = !empty(searchServiceName) ? searchService.outputs.name : '' 170 | output searchServiceEndpoint string = !empty(searchServiceName) ? searchService.outputs.endpoint : '' 171 | --------------------------------------------------------------------------------