├── 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 |
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 | Click me
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 |
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 |
4 |
5 | Put these codes in a safe place.
6 |
7 |
8 | If you lose your device and don't have the recovery codes you will lose access to your account.
9 |
10 |
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 |
5 | @DisplayMessage
6 |
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 |
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 |
7 |
8 | Profile
9 |
10 |
11 | Email
12 |
13 |
14 | Password
15 |
16 | @if (hasExternalLogins)
17 | {
18 |
19 | External logins
20 |
21 | }
22 |
23 | Two-factor authentication
24 |
25 |
26 | Personal data
27 |
28 |
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 |
16 | }
17 | else
18 | {
19 |
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 | Date
20 | Temp. (C)
21 | Temp. (F)
22 | Summary
23 |
24 |
25 |
26 | @foreach (var forecast in forecasts)
27 | {
28 |
29 | @forecast.Date.ToShortDateString()
30 | @forecast.TemperatureC
31 | @forecast.TemperatureF
32 | @forecast.Summary
33 |
34 | }
35 |
36 |
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 |
17 |
18 |
19 | If you reset your authenticator key your authenticator app will not work until you reconfigure it.
20 |
21 |
22 | This process disables 2FA until you verify your authenticator app.
23 | If you do not complete your authenticator app configuration you may lose access to your account.
24 |
25 |
26 |
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 |
17 |
18 | This action only disables 2FA.
19 |
20 |
21 | Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
22 | used in an authenticator app you should reset your authenticator keys.
23 |
24 |
25 |
26 |
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 |
21 |
22 |
23 | Put these codes in a safe place.
24 |
25 |
26 | If you lose your device and don't have the recovery codes you will lose access to your account.
27 |
28 |
29 | Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
30 | used in an authenticator app you should reset your authenticator keys.
31 |
32 |
33 |
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 | Email
29 |
30 |
31 | Resend
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 | Email
29 |
30 |
31 | Reset password
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 | Username
25 |
26 |
27 |
28 | Phone number
29 |
30 |
31 | Save
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 |
20 |
21 | Deleting this data will permanently remove your account, and this cannot be recovered.
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | @if (requirePassword)
30 | {
31 |
32 |
33 | Password
34 |
35 |
36 | }
37 | Delete data and close my account
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 | Recovery Code
29 |
30 |
31 | Log in
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 | New password
28 |
29 |
30 |
31 |
32 | Confirm password
33 |
34 |
35 | Set password
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 |
26 | }
27 | else if (recoveryCodesLeft == 1)
28 | {
29 |
33 | }
34 | else if (recoveryCodesLeft <= 3)
35 | {
36 |
40 | }
41 |
42 | if (isMachineRemembered)
43 | {
44 |
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 | Authenticator code
28 |
29 |
30 |
31 |
32 |
33 | Remember this machine
34 |
35 |
36 |
37 | Log in
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 | Email
28 |
29 |
30 |
31 |
32 | Password
33 |
34 |
35 |
36 |
37 | Confirm password
38 |
39 |
40 | Reset
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 | Old password
25 |
26 |
27 |
28 |
29 | New password
30 |
31 |
32 |
33 |
34 | Confirm password
35 |
36 |
37 | Update password
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 | 
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 | Email
28 |
29 |
30 |
31 |
32 | Password
33 |
34 |
35 |
36 |
37 |
38 | Remember me
39 |
40 |
41 |
42 | Log in
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 |
25 |
26 |
27 |
28 | @if (isEmailConfirmed)
29 | {
30 |
37 | }
38 | else
39 | {
40 |
41 |
42 | Email
43 | Send verification email
44 |
45 | }
46 |
47 |
48 | New email
49 |
50 |
51 | Change email
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 |
--------------------------------------------------------------------------------