Compare commits

...

31 Commits
0.1.0 ... 0.2.0

Author SHA1 Message Date
Leendert de Borst
bca7b2bc82 Merge pull request #41 from lanedirt/29-add-cache-busting-to-wasm-app-static-resources
Fix cache buster for release mode (#29)
2024-06-19 16:08:03 -07:00
Leendert de Borst
d51ae8d913 Fix cache buster for release mode (#29) 2024-06-20 00:56:23 +02:00
Leendert de Borst
c5d2b1da37 Merge pull request #40 from lanedirt/39-fix-init-script-colors-for-all-terminals
Update init.sh (#39)
2024-06-19 15:27:11 -07:00
Leendert de Borst
5d85b3a275 Update init.sh (#39) 2024-06-20 00:26:58 +02:00
Leendert de Borst
3ba8e54e56 Merge pull request #38 from lanedirt/33-refactor-jwt-keys-in-webapi-from-appsettingsjson-to-environment-variables
33 refactor jwt keys in webapi from appsettingsjson to environment variables
2024-06-19 15:13:13 -07:00
Leendert de Borst
7ffc1f1ee5 Fix E2E tests (#33) 2024-06-20 00:07:20 +02:00
Leendert de Borst
8d4024860b Add cache busting to index.html (#33) 2024-06-20 00:03:53 +02:00
Leendert de Borst
383145814a Add init.sh script for initial setup (#33) 2024-06-19 23:16:18 +02:00
Leendert de Borst
210f4b3c9e Add .env for JWT keys, minor refactoring (#33) 2024-06-19 21:51:54 +02:00
Leendert de Borst
276ceb3dce Merge pull request #36 from lanedirt/35-fix-code-style-issues
Fix code style issues
2024-06-18 15:07:18 -07:00
Leendert de Borst
2985c8333e Fix code style issues (#35) 2024-06-19 00:00:29 +02:00
Leendert de Borst
7bb8aee532 Add sonarcloud badges (#35) 2024-06-18 23:56:25 +02:00
Leendert de Borst
7de3b05985 Fix code style issues (#35) 2024-06-18 23:56:16 +02:00
Leendert de Borst
daca01a428 Merge pull request #34 from lanedirt/32-fix-linting-issues
Fix linting issues (#32)
2024-06-18 14:30:52 -07:00
Leendert de Borst
9fb19d28d6 Update sonarcloud-code-analysis (#32) 2024-06-18 23:21:20 +02:00
Leendert de Borst
540177c762 Fix linting issues (#32) 2024-06-18 23:18:16 +02:00
Leendert de Borst
228b037a6d Merge pull request #31 from lanedirt/30-add-sonarcloud-integration-for-code-analysis
Add sonarcloud github action
2024-06-18 14:04:29 -07:00
Leendert de Borst
0e0366564d Update CryptographyTests.cs (#30) 2024-06-18 22:56:42 +02:00
Leendert de Borst
cca91d6076 Update sonarcloud code analysis (#30) 2024-06-18 22:37:36 +02:00
Leendert de Borst
6c9e770af7 Update sonarcloud-code-analysis.yml (#30) 2024-06-18 22:01:38 +02:00
Leendert de Borst
44bcb7f16d Merge branch '30-add-sonarcloud-integration-for-code-analysis' of https://github.com/lanedirt/AliasVault into 30-add-sonarcloud-integration-for-code-analysis
* '30-add-sonarcloud-integration-for-code-analysis' of https://github.com/lanedirt/AliasVault:
  Add sonarcloud github action
2024-06-18 21:53:27 +02:00
Leendert de Borst
d69b3defe5 Add coverlet.msbuild (#30) 2024-06-18 21:53:26 +02:00
Leendert de Borst
02af26cb39 Add sonarcloud github action (#30) 2024-06-18 21:53:11 +02:00
Leendert de Borst
3cc3c67a4d Add sonarcloud github action 2024-06-18 21:41:28 +02:00
Leendert de Borst
107d2d8602 Merge pull request #28 from lanedirt/27-fix-wasm-topbar-menu-open-toggle-css-styles
WASM app tweaks for UI (#27)
2024-06-18 12:03:47 -07:00
Leendert de Borst
b8301d8f98 Merge pull request #26 from lanedirt/25-add-versioning-to-webapi-project
Add versioning to webapi project
2024-06-18 11:45:52 -07:00
Leendert de Borst
124491e5db WASM app tweaks for UI (#27) 2024-06-18 20:45:26 +02:00
Leendert de Borst
dbea1c2c4d Add api version prefix to WASM app URLs (#25) 2024-06-18 20:43:07 +02:00
Leendert de Borst
949a7a856a Add versioning to webapi (#25) 2024-06-18 20:41:21 +02:00
Leendert de Borst
b923669b66 Merge pull request #24 from lanedirt/23-add-form-validation-to-login-and-signup-pages
Add form validation to login/register pages
2024-06-17 13:11:32 -07:00
Leendert de Borst
da25aa43ea Add form validation to login/register pages (#23) 2024-06-17 22:08:49 +02:00
57 changed files with 995 additions and 425 deletions

View File

@@ -10,10 +10,7 @@ insert_final_newline = true
# C# files
[*.cs]
indent_style = space
indent_size = 4
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:silent
@@ -30,7 +27,6 @@ csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
dotnet_diagnostic.SA1011.severity = none
dotnet_diagnostic.SA1101.severity = none
dotnet_diagnostic.SA1200.severity = none
dotnet_diagnostic.SA1309.severity = none
dotnet_diagnostic.SA1310.severity = warning
dotnet_diagnostic.SX1309.severity = none
@@ -120,5 +116,8 @@ dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
end_of_line = crlf
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_coalesce_expression = false:suggestion
dotnet_style_null_propagation = false:suggestion
# IDE0046: Convert to conditional expression
dotnet_diagnostic.IDE0046.severity = silent

1
.env Normal file
View File

@@ -0,0 +1 @@
JWT_KEY=YprFMYAzrqY/R/DmDYZI1PS7qTyZYp4g

1
.env.example Normal file
View File

@@ -0,0 +1 @@
JWT_KEY=

View File

@@ -0,0 +1,49 @@
name: SonarCloud code analysis
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
name: Build and analyze
runs-on: windows-latest
steps:
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'zulu' # Alternative distribution options are available.
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Cache SonarCloud packages
uses: actions/cache@v3
with:
path: ~\sonar\cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache SonarCloud scanner
id: cache-sonar-scanner
uses: actions/cache@v3
with:
path: .\.sonar\scanner
key: ${{ runner.os }}-sonar-scanner
restore-keys: ${{ runner.os }}-sonar-scanner
- name: Install SonarCloud scanner
if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
shell: powershell
run: |
New-Item -Path .\.sonar\scanner -ItemType Directory
dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
- name: Build and analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
shell: powershell
run: |
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
dotnet build
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

4
.gitignore vendored
View File

@@ -370,3 +370,7 @@ FodyWeavers.xsd
.idea
*.licenseheader
# AliasVault specific
# index.html is generated by the build process from index.template.html and therefore should be ignored
src/AliasVault.WebApp/wwwroot/index.html

View File

@@ -1,12 +1,13 @@
<div align="center">
<h1>AliasVault</h1>
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/sonar/coverage/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=test code coverage">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
</div>
> Disclaimer: This repository is currently in an alpha state and is NOT ready for production use. Critical features, such as encryption, are not yet fully implemented. AliasVault is a work in progress and as of this moment serves as a research playground. Users are welcome to explore and use this project, but please be aware that there are no guarantees regarding its security or stability. Use at your own risk!
@@ -14,34 +15,56 @@
AliasVault is an open-source password manager that can generate virtual identities complete with virtual email addresses. AliasVault can be self-hosted on your own server with Docker, providing a secure and private solution for managing your online identities and passwords.
## Features
- Password Management: Securely store and manage your passwords.
- Virtual Identities: Generate virtual identities with virtual (working) email addresses.
- Data Protection: Ensures that all sensitive data is encrypted and securely stored.
- User Authentication: Secure login and user management functionalities.
- **Password Management:** Securely store and manage your passwords.
- **Virtual Identities:** Generate virtual identities with virtual (working) email addresses that are assigned to one or more passwords.
- **Zero-knowledge architecture:** Ensures that all sensitive data is end-to-end encrypted on the client and stored in encrypted state on the database. The server never has access to your data.
## Installation
1. Clone this repository.
### 1. Clone this repository.
```bash
git clone [URL]
# Clone this Git repository to "AliasVault" directory
$ git clone https://github.com/lanedirt/AliasVault.git
```
2. Run the app via Docker:
### 2. Run the init script to set up the .env file and generate a random encryption secret.
This script will create a .env file in the root directory of the project if it does not yet exist and populate it with a random encryption secret.
```bash
# Go to the project directory
$ cd AliasVault
# Make init script executable
$ chmod +x init.sh
# Run the init script
$ ./init.sh
```
### 3. Build and run the app via Docker:
```bash
docker compose up -d --build --force-recreate
# Build and run the app via Docker Compose
$ docker compose up -d --build --force-recreate
```
The app will be available at http://localhost:80
> Note: the container binds to port 80 by default. If you have another service running on port 80, you can change the port in the `docker-compose.yml` file.
#### Note for first time build:
- When running the app for the first time, it may take a few minutes to build the Docker image.
- A SQLite database file will be created in `./database/aliasdb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared.
## Credits
The following libraries and frameworks are used in this project:
After the Docker containers have started the app will be available at http://localhost:80
## Tech stack / credits
The following technologies, frameworks and libraries are used in this project:
- [C#](https://docs.microsoft.com/en-us/dotnet/csharp/) - A simple, modern, object-oriented, and type-safe programming language.
- [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) - An open-source framework for building modern, cloud-based, internet-connected applications.
- [Blazor](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript.
- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) - A lightweight, extensible, open-source and cross-platform version of the popular Entity Framework data access technology.
- [Blazor WASM](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript. It's a single-page app framework that runs in the browser via WebAssembly.
- [Docker](https://www.docker.com/) - A platform for building, sharing, and running containerized applications.
- [SQLite](https://www.sqlite.org/index.html) - A C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine.
- [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework for rapidly building custom designs.
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library.
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library based on Tailwind CSS.
- [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography) - A .NET library that implements Argon2id, a memory-hard password hashing algorithm.

View File

@@ -30,4 +30,6 @@ services:
- "81:8081"
volumes:
- ./database:/database
env_file:
- .env
restart: always

78
init.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/bin/sh
# Define colors for CLI output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Define the path to the .env and .env.example files
ENV_FILE=".env"
ENV_EXAMPLE_FILE=".env.example"
# Function to generate a new 32-character JWT key
generate_jwt_key() {
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
}
# Function to create .env file from .env.example if it doesn't exist
create_env_file() {
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
printf "${GREEN}> .env file created from .env.example.${NC}\n"
else
touch "$ENV_FILE"
printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n"
fi
else
printf "${CYAN}> .env file already exists.${NC}\n"
fi
}
# Function to check and populate the .env file with JWT_KEY
populate_jwt_key() {
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "${YELLOW}JWT_KEY not found or empty in $ENV_FILE. Generating a new JWT key...${NC}\n"
JWT_KEY=$(generate_jwt_key)
if grep -q "^JWT_KEY=" "$ENV_FILE"; then
awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
printf "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE\n"
fi
printf "${GREEN}> JWT_KEY has been added to $ENV_FILE.${NC}\n"
else
printf "${CYAN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to print the CLI logo
print_logo() {
printf "${MAGENTA}\n"
printf "=========================================================\n"
printf " _ _ __ __ _ _ \n"
printf " /\ | (_) \ \ / / | | | \n"
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
printf "\n"
printf "=========================================================\n"
printf "${NC}\n"
}
# Run the functions and print status
print_logo
printf "${BLUE}Initializing AliasVault...${NC}\n"
create_env_file
populate_jwt_key
printf "${BLUE}Initialization complete.${NC}\n"
printf "\n"
printf "To build the images and start the containers, run the following command:\n"
printf "\n"
printf "${CYAN}$ docker compose up -d --build --force-recreate${NC}\n"
printf "\n"
printf "\n"

View File

@@ -60,16 +60,17 @@ public class AliasDbContext : IdentityDbContext
/// <summary>
/// The OnModelCreating method.
/// </summary>
/// <param name="modelBuilder">ModelBuilder instance.</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
/// <param name="builder">ModelBuilder instance.</param>
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(modelBuilder);
foreach (var entity in modelBuilder.Model.GetEntityTypes())
base.OnModelCreating(builder);
foreach (var entity in builder.Model.GetEntityTypes())
{
foreach (var property in entity.GetProperties())
{
// TODO: This is a workaround for SQLite. Add conditional check if SQLite is used.
// TODO: SQL server doesn't need this override.
// NOTE: This is a workaround for SQLite. Add conditional check if SQLite is used.
// NOTE: SQL server doesn't need this override.
// SQLite does not support varchar(max) so we use TEXT.
if (property.ClrType == typeof(string) && property.GetMaxLength() == null)
{
@@ -79,42 +80,42 @@ public class AliasDbContext : IdentityDbContext
}
// Configure Identity - Login relationship
modelBuilder.Entity<Login>()
builder.Entity<Login>()
.HasOne(l => l.Identity)
.WithMany()
.HasForeignKey(l => l.IdentityId)
.OnDelete(DeleteBehavior.Cascade);
// Configure the Login - UserId entity
modelBuilder.Entity<Login>()
builder.Entity<Login>()
.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserId)
.IsRequired();
// Configure Login - Service relationship
modelBuilder.Entity<Login>()
builder.Entity<Login>()
.HasOne(l => l.Service)
.WithMany()
.HasForeignKey(l => l.ServiceId)
.OnDelete(DeleteBehavior.Cascade);
// Configure Login - Password relationship
modelBuilder.Entity<Login>()
builder.Entity<Login>()
.HasMany(l => l.Passwords)
.WithOne(p => p.Login)
.HasForeignKey(p => p.LoginId)
.OnDelete(DeleteBehavior.Cascade);
// Configure Identity - DefaultPassword relationship
modelBuilder.Entity<Identity>()
builder.Entity<Identity>()
.HasOne(i => i.DefaultPassword)
.WithMany()
.HasForeignKey(i => i.DefaultPasswordId)
.OnDelete(DeleteBehavior.SetNull);
// Configure the User - AspNetUserRefreshToken entity
modelBuilder.Entity<AspNetUserRefreshToken>()
builder.Entity<AspNetUserRefreshToken>()
.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserId)

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
// <auto-generated />
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

View File

@@ -15,6 +15,10 @@ public class FigIdentityGenerator : IIdentityGenerator
{
private static readonly HttpClient HttpClient = new();
private static readonly string Url = "https://api.identiteitgenerator.nl/generate/identity";
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc/>
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
@@ -23,10 +27,7 @@ public class FigIdentityGenerator : IIdentityGenerator
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, JsonSerializerOptions);
if (identity is null)
{

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -18,6 +18,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>

View File

@@ -10,6 +10,7 @@ namespace AliasVault.Api.Controllers;
using System.Globalization;
using AliasDb;
using AliasVault.Shared.Models.WebApi;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -21,6 +22,7 @@ using Service = AliasVault.Shared.Models.WebApi.Service;
/// </summary>
/// <param name="context">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
[ApiVersion("1")]
public class AliasController(AliasDbContext context, UserManager<IdentityUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>

View File

@@ -3,7 +3,7 @@
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
//-----------------------------------------------------------------------s
namespace AliasVault.Api.Controllers;
@@ -13,6 +13,8 @@ using System.Security.Cryptography;
using System.Text;
using AliasDb;
using AliasVault.Shared.Models;
using AliasVault.Shared.Models.WebApi;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
@@ -24,8 +26,9 @@ using Microsoft.IdentityModel.Tokens;
/// <param name="userManager">UserManager instance.</param>
/// <param name="signInManager">SignInManager instance.</param>
/// <param name="configuration">IConfiguration instance.</param>
[Route("api/[controller]")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1")]
public class AuthController(AliasDbContext context, UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, IConfiguration configuration) : ControllerBase
{
/// <summary>
@@ -39,11 +42,11 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
var user = await userManager.FindByEmailAsync(model.Email);
if (user != null && await userManager.CheckPasswordAsync(user, model.Password))
{
var tokenModel = await GenerateNewTokenForUser(user);
var tokenModel = await GenerateNewTokensForUser(user);
return Ok(tokenModel);
}
return Unauthorized();
return BadRequest(ServerValidationErrorResponse.Create("Invalid username or password. Please try again.", 400));
}
/// <summary>
@@ -148,15 +151,40 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
await signInManager.SignInAsync(user, isPersistent: false);
// Return the token.
var tokenModel = await GenerateNewTokenForUser(user);
var tokenModel = await GenerateNewTokensForUser(user);
return Ok(tokenModel);
}
else
{
return BadRequest(result.Errors);
}
var errors = result.Errors.Select(e => e.Description).ToArray();
return BadRequest(ServerValidationErrorResponse.Create(errors, 400));
}
/// <summary>
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
/// with a specific device for a specific user.
///
/// NOTE: current implementation means that only one refresh token can be valid for a
/// specific user/device combo at a time. The identifier generation could be made more unique in the future
/// to prevent any unwanted conflicts.
/// </summary>
/// <param name="request">The HttpRequest instance for the request that the client used.</param>
/// <returns>Unique device identifier as string.</returns>
private static string GenerateDeviceIdentifier(HttpRequest request)
{
var userAgent = request.Headers.UserAgent.ToString();
var acceptLanguage = request.Headers.AcceptLanguage.ToString();
var rawIdentifier = $"{userAgent}|{acceptLanguage}";
return rawIdentifier;
}
/// <summary>
/// Generate a Jwt access token for a user. This token is used to authenticate the user for a limited time
/// and is short-lived by design. With the separate refresh token, the user can request a new access token
/// when this access token expires.
/// </summary>
/// <param name="user">The user to generate the Jwt access token for.</param>
/// <returns>Access token as string.</returns>
private string GenerateJwtToken(IdentityUser user)
{
var claims = new List<Claim>
@@ -167,19 +195,30 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? string.Empty));
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
if (jwtKey is null)
{
throw new KeyNotFoundException("JWT_KEY environment variable is not set.");
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: configuration["Jwt:Issuer"] ?? string.Empty,
audience: configuration["Jwt:Issuer"] ?? string.Empty,
claims: claims,
expires: DateTime.Now.AddMinutes(30),
expires: DateTime.Now.AddMinutes(10),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// Generate a refresh token for a user. This token is used to request a new access token when the current
/// access token expires. The refresh token is long-lived by design.
/// </summary>
/// <returns>Random string to be used as refresh token.</returns>
private string GenerateRefreshToken()
{
var randomNumber = new byte[32];
@@ -210,17 +249,13 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
return principal;
}
private string GenerateDeviceIdentifier(HttpRequest request)
{
// TODO: Add more headers to the device identifier or let client send a unique identifier instead.
var userAgent = request.Headers.UserAgent.ToString();
var acceptLanguage = request.Headers.AcceptLanguage.ToString();
var rawIdentifier = $"{userAgent}|{acceptLanguage}";
return rawIdentifier;
}
private async Task<TokenModel> GenerateNewTokenForUser(IdentityUser user)
/// <summary>
/// Generates a new access and refresh token for a user and persists the refresh token
/// to the database.
/// </summary>
/// <param name="user">The user to generate the tokens for.</param>
/// <returns>TokenModel which includes new access and refresh token.</returns>
private async Task<TokenModel> GenerateNewTokensForUser(IdentityUser user)
{
var token = GenerateJwtToken(user);
var refreshToken = GenerateRefreshToken();

View File

@@ -15,7 +15,7 @@ using Microsoft.AspNetCore.Mvc;
/// Base controller for requests that require authentication.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
[Route("api/[controller]")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[Authorize]
public class AuthenticatedRequestController(UserManager<IdentityUser> userManager) : ControllerBase

View File

@@ -8,6 +8,7 @@ namespace AliasVault.Api.Controllers;
using AliasGenerators.Identity;
using AliasGenerators.Identity.Implementations;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -15,6 +16,7 @@ using Microsoft.AspNetCore.Mvc;
/// Controller for identity generation.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
[ApiVersion("1")]
public class IdentityController(UserManager<IdentityUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
@@ -30,7 +32,7 @@ public class IdentityController(UserManager<IdentityUser> userManager) : Authent
return Unauthorized();
}
IIdentityGenerator identityGenerator = new FigIdentityGenerator();
var identityGenerator = new FigIdentityGenerator();
return Ok(await identityGenerator.GenerateRandomIdentityAsync());
}
}

View File

@@ -19,12 +19,12 @@ COPY . .
# Build the WebApi project
WORKDIR "/src/src/AliasVault.Api"
RUN dotnet build "AliasVault.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN dotnet build "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
# Publish the application to the /app/publish directory in the container
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "AliasVault.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
# Create the migration bundle
# Install the Entity Framework Core CLI tool and run migrations to create the database

View File

@@ -8,6 +8,7 @@
using System.Data.Common;
using System.Text;
using AliasDb;
using Asp.Versioning;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
@@ -26,7 +27,6 @@ builder.Services.AddLogging(logging =>
logging.AddFilter("Microsoft.AspNetCore.Identity.UserManager", LogLevel.Error);
});
// Add services to the container.
builder.Services.AddSingleton<DbConnection>(container =>
{
var configFile = new ConfigurationBuilder()
@@ -73,6 +73,12 @@ builder.Services.AddAuthentication(options =>
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
if (jwtKey is null)
{
throw new KeyNotFoundException("JWT_KEY environment variable is not set.");
}
options.IncludeErrorDetails = true;
options.TokenValidationParameters = new TokenValidationParameters
{
@@ -83,7 +89,7 @@ builder.Services.AddAuthentication(options =>
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? string.Empty)),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero,
};
});
@@ -99,7 +105,19 @@ builder.Services.AddCors(options =>
});
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "AliasVault API", Version = "v1" });

View File

@@ -1,33 +1,26 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:39952",
"sslPort": 44368
}
},
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5092",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"ASPNETCORE_ENVIRONMENT": "Development",
"JWT_KEY": "12345678901234567890123456789012"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5092"
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7223;http://localhost:5092",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"ASPNETCORE_ENVIRONMENT": "Development",
"JWT_KEY": "12345678901234567890123456789012"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7223;http://localhost:5092"
},
"IIS Express": {
"commandName": "IISExpress",
@@ -37,5 +30,14 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:39952",
"sslPort": 44368
}
}
}

View File

@@ -6,8 +6,7 @@
}
},
"Jwt": {
"Key": "[[&lokl$4r<ak{f}4d#iv7>92i*)=sfo",
"Issuer": "YourIssuer"
"Issuer": "AliasVault"
},
"ConnectionStrings": {
"AliasDbContext": "Data Source=../../database/aliasdb.sqlite"

View File

@@ -7,6 +7,8 @@
namespace AliasVault.Shared.Models;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Login model.
/// </summary>
@@ -15,10 +17,13 @@ public class LoginModel
/// <summary>
/// Gets or sets the email.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
public string Password { get; set; } = null!;
}

View File

@@ -7,6 +7,9 @@
namespace AliasVault.Shared.Models;
using System.ComponentModel.DataAnnotations;
using AliasVault.Shared.Models.Validation;
/// <summary>
/// Register model.
/// </summary>
@@ -15,20 +18,27 @@ public class RegisterModel
/// <summary>
/// Gets or sets the email.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the password confirmation.
/// </summary>
[Required]
[Compare("Password", ErrorMessage = "Passwords do not match.")]
public string PasswordConfirm { get; set; } = null!;
/// <summary>
/// Gets or sets a value indicating whether the terms and conditions are accepted or not.
/// </summary>
[MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")]
public bool AcceptTerms { get; set; } = false;
}

View File

@@ -0,0 +1,30 @@
//-----------------------------------------------------------------------
// <copyright file="MustBeTrueAttribute.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.Validation;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Validation attribute to ensure that a boolean property is true.
/// </summary>
public class MustBeTrueAttribute : ValidationAttribute
{
/// <inheritdoc />
public override bool IsValid(object? value)
{
switch (value)
{
case null:
return false;
case bool b:
return b;
default:
throw new InvalidOperationException("Can only be used on boolean properties.");
}
}
}

View File

@@ -0,0 +1,47 @@
//-----------------------------------------------------------------------
// <copyright file="AliasEdit.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Alias model.
/// </summary>
public class AliasEdit
{
/// <summary>
/// Gets or sets the name of the service.
/// </summary>
[Required]
public string ServiceName { get; set; } = null!;
/// <summary>
/// Gets or sets the URL of the service.
/// </summary>
public string? ServiceUrl { get; set; }
/// <summary>
/// Gets or sets the Alias Identity object.
/// </summary>
public Identity Identity { get; set; } = null!;
/// <summary>
/// Gets or sets the Alias Password object.
/// </summary>
public Password Password { get; set; } = null!;
/// <summary>
/// Gets or sets the Alias CreateDate.
/// </summary>
public DateTime CreateDate { get; set; }
/// <summary>
/// Gets or sets the Alias LastUpdate.
/// </summary>
public DateTime LastUpdate { get; set; }
}

View File

@@ -0,0 +1,93 @@
//-----------------------------------------------------------------------
// <copyright file="ServerValidationErrorResponse.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
using System.Text.Json.Serialization;
/// <summary>
/// Represents the structure of a validation error response from the API.
/// </summary>
public class ServerValidationErrorResponse
{
/// <summary>
/// Gets or sets the type of the error.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = null!;
/// <summary>
/// Gets or sets the title of the error.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = null!;
/// <summary>
/// Gets or sets the HTTP status code of the response.
/// </summary>
[JsonPropertyName("status")]
public int Status { get; set; }
/// <summary>
/// Gets or sets the validation errors. The key is the name of the field that has the error, and the value is an array of error messages for that field.
/// </summary>
[JsonPropertyName("errors")]
public Dictionary<string, string[]> Errors { get; set; } = new();
/// <summary>
/// Gets or sets the trace ID of the error.
/// </summary>
[JsonPropertyName("traceId")]
public string TraceId { get; set; } = null!;
/// <summary>
/// Creates a new instance of <see cref="ServerValidationErrorResponse"/>.
/// </summary>
/// <param name="title">Title of the error.</param>
/// <param name="status">Status code.</param>
/// <returns>ServerValidationErrorResponse object.</returns>
public static ServerValidationErrorResponse Create(string title, int status)
{
var errors = new Dictionary<string, string[]>
{
{ title, [title] },
};
return new ServerValidationErrorResponse
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = title,
Errors = errors,
Status = status,
TraceId = Guid.NewGuid().ToString(),
};
}
/// <summary>
/// Creates a new instance of <see cref="ServerValidationErrorResponse"/>.
/// </summary>
/// <param name="errorArray">Array with errors.</param>
/// <param name="status">Status code.</param>
/// <returns>ServerValidationErrorResponse object.</returns>
public static ServerValidationErrorResponse Create(string[] errorArray, int status)
{
var errors = new Dictionary<string, string[]>();
foreach (var t in errorArray)
{
errors.Add(t, new[] { t });
}
return new ServerValidationErrorResponse
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = errorArray[0],
Errors = errors,
Status = status,
TraceId = Guid.NewGuid().ToString(),
};
}
}

View File

@@ -7,6 +7,8 @@
namespace AliasVault.Shared.Models.WebApi;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Service model.
/// </summary>
@@ -15,6 +17,7 @@ public class Service
/// <summary>
/// Gets or sets the name of the service.
/// </summary>
[Required]
public string Name { get; set; } = null!;
/// <summary>

View File

@@ -5,11 +5,21 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<BuildVersion>$([System.DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss"))</BuildVersion>
</PropertyGroup>
<Target Name="GenerateCacheBustedIndexHtml" BeforeTargets="Build">
<Exec Command="dotnet msbuild -t:ReplaceText -p:CacheBuster=$(CacheBuster);BuildVersion='$(BuildVersion)'" />
</Target>
<Target Name="ReplaceText">
<Exec Command="sed 's/@CacheBuster/$(CacheBuster)/g;s/@BuildVersion/$(BuildVersion)/g' wwwroot/index.template.html > wwwroot/index.html" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\net8.0\AliasVault.WebApp.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CacheBuster>dev</CacheBuster>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
@@ -17,6 +27,7 @@
<DocumentationFile>bin\Release\net8.0\AliasVault.WebApp.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Optimize>True</Optimize>
<CacheBuster>$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</CacheBuster>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,13 +1,13 @@
@page "/user/login"
@attribute [AllowAnonymous]
@layout Auth.Layout.MainLayout
@inject HttpClient Http
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject AuthService AuthService
@using System.Text.Json
@using AliasVault.Shared.Models
@using AliasVault.Shared.Models.WebApi
@using AliasVault.WebApp.Auth.Components
@using AliasVault.WebApp.Auth.Services
@@ -15,14 +15,20 @@
Sign in to AliasVault
</h2>
<EditForm Model="_user" OnSubmit="HandleLogin" class="mt-8 space-y-6">
<FullScreenLoadingIndicator @ref="_loadingIndicator" />
<ServerValidationErrors @ref="_serverValidationErrors" />
<EditForm Model="_loginModel" OnValidSubmit="HandleLogin" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<InputTextField id="email" @bind-Value="_user.Email" placeholder="name@company.com" />
<InputTextField id="email" @bind-Value="_loginModel.Email" placeholder="name@company.com" />
<ValidationMessage For="() => _loginModel.Email"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="_user.Password" type="password" placeholder="••••••••" />
<InputTextField id="password" @bind-Value="_loginModel.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => _loginModel.Password"/>
</div>
<div class="flex items-start">
@@ -41,12 +47,10 @@
</div>
</EditForm>
<FullScreenLoadingIndicator @ref="_loadingIndicator" />
@code {
private LoginModel _user = new LoginModel();
private readonly LoginModel _loginModel = new();
private FullScreenLoadingIndicator _loadingIndicator = new();
private ServerValidationErrors _serverValidationErrors = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -62,11 +66,20 @@
private async Task HandleLogin()
{
_loadingIndicator.Show();
_serverValidationErrors.Clear();
try
{
var result = await Http.PostAsJsonAsync("api/Auth/login", _user);
var result = await Http.PostAsJsonAsync("api/v1/Auth/login", _loginModel);
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
_serverValidationErrors.ParseResponse(responseContent);
StateHasChanged();
return;
}
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
if (tokenObject != null)
@@ -80,6 +93,7 @@
{
// Handle the case where the token is not present in the response
Console.WriteLine("Token not found in the response.");
return;
}
await AuthStateProvider.GetAuthenticationStateAsync();

View File

@@ -1,10 +1,11 @@
@page "/user/logout"
@using AliasVault.WebApp.Auth.Services
@attribute [AllowAnonymous]
@layout Auth.Layout.MainLayout
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject AuthService AuthService
@inject GlobalNotificationService GlobalNotificationService
@using AliasVault.WebApp.Auth.Services
@code {
/// <inheritdoc />
@@ -13,6 +14,7 @@
await base.OnInitializedAsync();
await AuthService.RemoveTokensAsync();
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.ClearMessages();
// Redirect to home page
NavigationManager.NavigateTo("/");

View File

@@ -14,26 +14,34 @@
Create a Free Account
</h2>
<EditForm Model="user" OnSubmit="HandleRegister" class="mt-8 space-y-6">
<FullScreenLoadingIndicator @ref="_loadingIndicator" />
<ServerValidationErrors @ref="_serverValidationErrors" />
<EditForm Model="_registerModel" OnValidSubmit="HandleRegister" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<InputTextField id="email" @bind-Value="user.Email" placeholder="name@company.com" />
<InputTextField id="email" @bind-Value="_registerModel.Email" placeholder="name@company.com" />
<ValidationMessage For="() => _registerModel.Email"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="user.Password" type="password" placeholder="••••••••" />
<InputTextField id="password" @bind-Value="_registerModel.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => _registerModel.Password"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm password</label>
<InputTextField id="password2" @bind-Value="user.PasswordConfirm" type="password" placeholder="••••••••" />
<InputTextField id="password2" @bind-Value="_registerModel.PasswordConfirm" type="password" placeholder="••••••••" />
<ValidationMessage For="() => _registerModel.PasswordConfirm"/>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="terms" aria-describedby="terms" name="terms" type="checkbox" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" required>
<InputCheckbox id="terms" @bind-Value="_registerModel.AcceptTerms" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" />
</div>
<div class="ml-3 text-sm">
<label for="terms" class="font-medium text-gray-900 dark:text-white">I accept the <a href="#" class="text-primary-700 hover:underline dark:text-primary-500">Terms and Conditions</a></label>
<ValidationMessage For="() => _registerModel.AcceptTerms"/>
</div>
</div>
@@ -43,79 +51,48 @@
</div>
</EditForm>
@if (validationErrors.Any())
{
<div class="alert alert-danger">
<ul>
@foreach (var error in validationErrors)
{
<li>@error</li>
}
</ul>
</div>
}
<FullScreenLoadingIndicator @ref="loadingIndicator" />
@code {
RegisterModel user = new();
FullScreenLoadingIndicator loadingIndicator = new();
List<string> validationErrors = [];
private readonly RegisterModel _registerModel = new();
private FullScreenLoadingIndicator _loadingIndicator = new();
private ServerValidationErrors _serverValidationErrors = new();
async Task HandleRegister()
{
loadingIndicator.Show();
validationErrors.Clear();
_loadingIndicator.Show();
_serverValidationErrors.Clear();
try
{
var result = await Http.PostAsJsonAsync("api/Auth/register", user);
var result = await Http.PostAsJsonAsync("api/v1/Auth/register", _registerModel);
var responseContent = await result.Content.ReadAsStringAsync();
if (result.IsSuccessStatusCode)
if (!result.IsSuccessStatusCode)
{
var responseContent = await result.Content.ReadAsStringAsync();
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
_serverValidationErrors.ParseResponse(responseContent);
StateHasChanged();
return;
}
if (tokenObject != null)
{
// Store the token as a plain string in local storage
await AuthService.StoreAccessTokenAsync(tokenObject.Token);
await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
await AuthStateProvider.GetAuthenticationStateAsync();
}
else
{
// Handle the case where the token is not present in the response
Console.WriteLine("Token not found in the response.");
}
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
NavigationManager.NavigateTo("/");
if (tokenObject != null)
{
// Store the token as a plain string in local storage
await AuthService.StoreAccessTokenAsync(tokenObject.Token);
await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
await AuthStateProvider.GetAuthenticationStateAsync();
}
else
{
var responseContent = await result.Content.ReadAsStringAsync();
var errorResponse = System.Text.Json.JsonSerializer.Deserialize<ValidationErrorResponse>(responseContent);
if (errorResponse != null && errorResponse.Errors != null)
{
foreach (var error in errorResponse.Errors.Values)
{
validationErrors.AddRange(error);
}
}
// Handle the case where the token is not present in the response
Console.WriteLine("Token not found in the response.");
}
NavigationManager.NavigateTo("/");
}
finally
{
loadingIndicator.Hide();
_loadingIndicator.Hide();
}
}
public class ValidationErrorResponse
{
public string Type { get; set; } = null!;
public string Title { get; set; } = null!;
public int Status { get; set; }
public Dictionary<string, string[]> Errors { get; set; } = new();
public string TraceId { get; set; } = null!;
}
}

View File

@@ -28,7 +28,7 @@ public class AuthStateProvider(AuthService authService) : AuthenticationStatePro
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
if (keyValuePairs == null)
if (keyValuePairs is null)
{
throw new InvalidOperationException("Failed to parse JWT token.");
}

View File

@@ -16,23 +16,15 @@ using Blazored.LocalStorage;
/// This service is responsible for handling authentication-related operations such as refreshing tokens,
/// storing tokens, and revoking tokens.
/// </summary>
public class AuthService
/// <remarks>
/// Initializes a new instance of the <see cref="AuthService"/> class.
/// </remarks>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="localStorage">The local storage service.</param>
public class AuthService(HttpClient httpClient, ILocalStorageService localStorage)
{
private const string AccessTokenKey = "token";
private const string RefreshTokenKey = "refreshToken";
private readonly HttpClient _httpClient;
private readonly ILocalStorageService _localStorage;
/// <summary>
/// Initializes a new instance of the <see cref="AuthService"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="localStorage">The local storage service.</param>
public AuthService(HttpClient httpClient, ILocalStorageService localStorage)
{
_httpClient = httpClient;
_localStorage = localStorage;
}
/// <summary>
/// Refreshes the access token asynchronously.
@@ -44,14 +36,14 @@ public class AuthService
var accessToken = await GetAccessTokenAsync();
var refreshToken = await GetRefreshTokenAsync();
var tokenInput = new TokenModel { Token = accessToken, RefreshToken = refreshToken };
using var request = new HttpRequestMessage(HttpMethod.Post, "api/Auth/refresh")
using var request = new HttpRequestMessage(HttpMethod.Post, "api/v1/Auth/refresh")
{
Content = JsonContent.Create(tokenInput),
};
// Add the X-Ignore-Failure header to the request so any failure does not trigger another refresh token request.
request.Headers.Add("X-Ignore-Failure", "true");
var response = await _httpClient.SendAsync(request);
var response = await httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
@@ -77,7 +69,7 @@ public class AuthService
/// <returns>The stored access token.</returns>
public async Task<string> GetAccessTokenAsync()
{
return await _localStorage.GetItemAsStringAsync(AccessTokenKey) ?? string.Empty;
return await localStorage.GetItemAsStringAsync(AccessTokenKey) ?? string.Empty;
}
/// <summary>
@@ -87,7 +79,7 @@ public class AuthService
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task StoreAccessTokenAsync(string newToken)
{
await _localStorage.SetItemAsStringAsync(AccessTokenKey, newToken);
await localStorage.SetItemAsStringAsync(AccessTokenKey, newToken);
}
/// <summary>
@@ -96,7 +88,7 @@ public class AuthService
/// <returns>The stored refresh token.</returns>
public async Task<string> GetRefreshTokenAsync()
{
return await _localStorage.GetItemAsStringAsync(RefreshTokenKey) ?? string.Empty;
return await localStorage.GetItemAsStringAsync(RefreshTokenKey) ?? string.Empty;
}
/// <summary>
@@ -106,7 +98,7 @@ public class AuthService
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task StoreRefreshTokenAsync(string newToken)
{
await _localStorage.SetItemAsStringAsync(RefreshTokenKey, newToken);
await localStorage.SetItemAsStringAsync(RefreshTokenKey, newToken);
}
/// <summary>
@@ -115,8 +107,8 @@ public class AuthService
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task RemoveTokensAsync()
{
await _localStorage.RemoveItemAsync(AccessTokenKey);
await _localStorage.RemoveItemAsync(RefreshTokenKey);
await localStorage.RemoveItemAsync(AccessTokenKey);
await localStorage.RemoveItemAsync(RefreshTokenKey);
// If the remote call fails we catch the exception and ignore it.
// This is because the user is already logged out and we don't want to trigger another refresh token request.
@@ -142,13 +134,13 @@ public class AuthService
RefreshToken = await GetRefreshTokenAsync(),
};
using var request = new HttpRequestMessage(HttpMethod.Post, "api/Auth/revoke")
using var request = new HttpRequestMessage(HttpMethod.Post, "api/v1/Auth/revoke")
{
Content = JsonContent.Create(tokenInput),
};
// Add the X-Ignore-Failure header to the request so any failure does not trigger another refresh token request.
request.Headers.Add("X-Ignore-Failure", "true");
await _httpClient.SendAsync(request);
await httpClient.SendAsync(request);
}
}

View File

@@ -11,6 +11,9 @@
</div>
@code {
/// <summary>
/// The message to show.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -11,7 +11,9 @@
</div>
@code {
/// <summary>
/// The message to show.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -48,20 +48,20 @@
/// <summary>
/// Refreshes the messages by adding any new messages from the PortalMessageService.
/// </summary>
public void RefreshAddMessages()
private void RefreshAddMessages()
{
// We retrieve any additional messages from the GlobalNotificationService that we do not yet have.
var newMessages = GlobalNotificationService.GetMessagesForDisplay();
foreach (var message in newMessages)
{
if (!Messages.Any(m => m.Key == message.Key && m.Value == message.Value))
if (!Messages.Exists(m => m.Key == message.Key && m.Value == message.Value))
{
Messages.Add(message);
}
}
// Remove messages that are no longer in the GlobalNotificationService and have already been displayed.
var messagesToRemove = Messages.Where(m => !newMessages.Any(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList();
var messagesToRemove = Messages.Where(m => !newMessages.Exists(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList();
foreach (var message in messagesToRemove)
{
Messages.Remove(message);

View File

@@ -46,7 +46,7 @@
{
base.OnInitialized();
// Remove first item if it is the home page
if (BreadcrumbItems.Any() && BreadcrumbItems.First().DisplayName == "Home")
if (BreadcrumbItems.Any() && BreadcrumbItems[0].DisplayName == "Home")
{
BreadcrumbItems.RemoveAt(0);
}

View File

@@ -20,7 +20,7 @@
[Parameter] public string Value { get; set; } = string.Empty;
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
private string _inputId = Guid.NewGuid().ToString();
private readonly string _inputId = Guid.NewGuid().ToString();
protected override void OnInitialized()
{

View File

@@ -0,0 +1,40 @@
@using AliasVault.Shared.Models.WebApi
@if (_errors.Any())
{
@foreach (var error in _errors)
{
<AlertMessageError Message="@error" />
}
}
@code {
private readonly List<string> _errors = [];
/// <summary>
/// Parses the response content and displays the server validation errors.
/// </summary>
public void ParseResponse(string responseContent)
{
_errors.Clear();
var errorResponse = System.Text.Json.JsonSerializer.Deserialize<ServerValidationErrorResponse>(responseContent);
if (errorResponse is not null)
{
foreach (var error in errorResponse.Errors)
{
_errors.AddRange(error.Value);
}
}
StateHasChanged();
}
/// <summary>
/// Clears the server validation errors.
/// </summary>
public void Clear()
{
_errors.Clear();
StateHasChanged();
}
}

View File

@@ -20,12 +20,12 @@ COPY . .
# Build the WebApp project
WORKDIR "/src/src/AliasVault.WebApp"
RUN dotnet build "AliasVault.WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN dotnet build "AliasVault.WebApp.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
# Publish the WebApp project
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "AliasVault.WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "AliasVault.WebApp.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
# Final stage: start nginx and serve static html files that were published in the previous stage
FROM nginx:alpine AS final

View File

@@ -1,4 +1,5 @@
@inherits PageBase
@implements IDisposable
<header>
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4">
@@ -41,9 +42,9 @@
@if (isMenuOpen)
{
<div class="z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom" style="position: absolute; inset: 0px auto auto 0px; margin: 0px; margin-left: -200px; transform: translate3d(1537.5px, 58px, 0px);">
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@_username</span>
</div>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
@@ -58,73 +59,84 @@
</div>
}
<button type="button" id="toggleMobileMenuButton" data-collapse-toggle="toggleMobileMenu" class="items-center p-2 text-gray-500 rounded-lg md:ml-2 lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600">
<button @onclick="ToggleMobileMenu" type="button" id="toggleMobileMenuButton" class="items-center p-2 text-gray-500 rounded-lg md:ml-2 lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600">
<span class="sr-only">Open menu</span>
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
</button>
</div>
</div>
</nav>
<nav class="bg-white dark:bg-gray-900">
<ul id="toggleMobileMenu" class="hidden flex-col mt-0 pt-16 w-full text-sm font-medium lg:hidden">
<li class="block border-b dark:border-gray-700">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" aria-current="page">Home</a>
</li>
<li class="block border-b dark:border-gray-700">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Messages</a>
</li>
<li class="block border-b dark:border-gray-700">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Profile</a>
</li>
<li class="block border-b dark:border-gray-700">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Settings</a>
</li>
<li class="block border-b dark:border-gray-700">
<button type="button" data-collapse-toggle="dropdownMobileNavbar" class="flex justify-between items-center py-3 px-4 w-full text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Dropdown <svg class="w-6 h-6 text-gray-500 dark:text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg></button>
<ul id="dropdownMobileNavbar" class="hidden">
<li class="block border-t border-b dark:border-gray-700">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Item 1</a>
</li>
<li class="block border-b dark:border-gray-700">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Item 2</a>
</li>
<li class="block">
<a href="#" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0">Item 3</a>
</li>
</ul>
</li>
</ul>
</nav>
@if (isMobileMenuOpen)
{
<nav class="bg-white dark:bg-gray-900">
<ul id="mobileMenu" class="flex-col mt-0 pt-16 w-full text-sm font-medium lg:hidden">
<li class="block border-b dark:border-gray-700">
<NavLink href="/" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Home
</NavLink>
</li>
<li class="block border-b dark:border-gray-700">
<NavLink href="/aliases" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Aliases
</NavLink>
</li>
</ul>
</nav>
}
</header>
@code {
private bool isMenuOpen = false;
public string Username { get; set; } = "";
private bool isMobileMenuOpen = false;
private string _username { get; set; } = "";
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
Username = await GetUsernameAsync();
_username = await GetUsernameAsync();
NavigationManager.LocationChanged += LocationChanged;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await Js.InvokeVoidAsync("window.initTopMenu");
DotNetObjectReference<TopMenu> objRef = DotNetObjectReference.Create(this);
await Js.InvokeVoidAsync("window.registerClickOutsideHandler", objRef);
}
}
void LocationChanged(object? sender, LocationChangedEventArgs e)
{
isMenuOpen = false;
isMobileMenuOpen = false;
StateHasChanged();
}
private void ToggleMenu()
{
isMenuOpen = !isMenuOpen;
}
private void CloseMenu()
private void ToggleMobileMenu()
{
isMobileMenuOpen = !isMobileMenuOpen;
}
[JSInvokable]
public void CloseMenu()
{
isMenuOpen = false;
isMobileMenuOpen = false;
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= LocationChanged;
}
}

View File

@@ -6,6 +6,7 @@
@using AliasGenerators.Implementations
@using AliasGenerators.Password.Implementations
@using AliasVault.Shared.Models.WebApi
@using System.Globalization
@if (EditMode)
{
@@ -43,103 +44,107 @@ else {
}
else
{
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Service</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-name" Label="Service Name" Value="@(Obj.Service.Name)" ValueChanged="@(val => Obj.Service.Name = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-url" Label="Service URL" Value="@(Obj.Service.Url)" ValueChanged="@(val => Obj.Service.Url = val)"></EditFormRow>
<EditForm Model="Obj" OnValidSubmit="SaveAlias">
<DataAnnotationsValidator />
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Service</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-name" Label="Service Name" @bind-Value="Obj.ServiceName"></EditFormRow>
<ValidationMessage For="() => Obj.ServiceName"/>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-url" Label="Service URL" @bind-Value="Obj.ServiceUrl"></EditFormRow>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Login credentials</h3>
<div class="mb-4">
<button class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800" @onclick="GenerateRandomIdentity">Generate Random Identity</button>
<button class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800" @onclick="SaveAlias">Save Alias</button>
@if (IsIdentityLoading)
{
<p>Loading...</p>
}
</div>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="email" Label="Email" Value="@(Obj.Identity.EmailPrefix)" ValueChanged="@(val => Obj.Identity.EmailPrefix = val)"></EditFormRow>
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Login credentials</h3>
<div class="mb-4">
<button type="button" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800" @onclick="GenerateRandomIdentity">Generate Random Identity</button>
<button type="submit" class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800">Save Alias</button>
@if (IsIdentityLoading)
{
<p>Loading...</p>
}
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="username" Label="Username" Value="@(Obj.Identity.NickName)" ValueChanged="@(val => Obj.Identity.NickName = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<div class="relative">
<EditFormRow Id="password" Label="Password" Value="@(Obj.Password.Value)" ValueChanged="@(val => Obj.Password.Value = val)"></EditFormRow>
<button type="submit" class="text-white absolute end-1 bottom-1 bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800" @onclick="GenerateRandomPassword">(Re)generate Random Password</button>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="email" Label="Email" @bind-Value="Obj.Identity.EmailPrefix"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="username" Label="Username" @bind-Value="Obj.Identity.NickName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<div class="relative">
<EditFormRow Id="password" Label="Password" @bind-Value="Obj.Password.Value"></EditFormRow>
<button type="submit" class="text-white absolute end-1 bottom-1 bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800" @onclick="GenerateRandomPassword">(Re)generate Random Password</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Identity</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="first-name" Label="First Name" Value="@(Obj.Identity.FirstName)" ValueChanged="@(val => Obj.Identity.FirstName = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="last-name" Label="Last Name" Value="@(Obj.Identity.LastName)" ValueChanged="@(val => Obj.Identity.LastName = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="gender" Label="Gender" Value="@(Obj.Identity.Gender)" ValueChanged="@(val => Obj.Identity.Gender = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="nickname" Label="Nick Name" Value="@(Obj.Identity.NickName)" ValueChanged="@(val => Obj.Identity.NickName = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="birthdate" Label="Birth Date" Value="@(Obj.Identity.BirthDate)" ValueChanged="@(val => Obj.Identity.BirthDate = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="street" Label="Address Street" Value="@(Obj.Identity.AddressStreet)" ValueChanged="@(val => Obj.Identity.AddressStreet = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="city" Label="Address City" Value="@(Obj.Identity.AddressCity)" ValueChanged="@(val => Obj.Identity.AddressCity = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="state" Label="Address State" Value="@(Obj.Identity.AddressState)" ValueChanged="@(val => Obj.Identity.AddressState = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="zipcode" Label="Address Zip Code" Value="@(Obj.Identity.AddressZipCode)" ValueChanged="@(val => Obj.Identity.AddressZipCode = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="country" Label="Address Country" Value="@(Obj.Identity.AddressCountry)" ValueChanged="@(val => Obj.Identity.AddressCountry = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="hobbies" Label="Hobbies" Value="@(Obj.Identity.Hobbies)" ValueChanged="@(val => Obj.Identity.Hobbies = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="phone-mobile" Label="Phone Mobile" Value="@(Obj.Identity.PhoneMobile)" ValueChanged="@(val => Obj.Identity.PhoneMobile = val)"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="iban" Label="Bank Account IBAN" Value="@(Obj.Identity.BankAccountIBAN)" ValueChanged="@(val => Obj.Identity.BankAccountIBAN = val)"></EditFormRow>
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Identity</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="first-name" Label="First Name" @bind-Value="Obj.Identity.FirstName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="last-name" Label="Last Name" @bind-Value="Obj.Identity.LastName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="gender" Label="Gender" @bind-Value="Obj.Identity.Gender"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="nickname" Label="Nick Name" @bind-Value="Obj.Identity.NickName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="birthdate" Label="Birth Date" @bind-Value="Obj.Identity.BirthDate"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="street" Label="Address Street" @bind-Value="Obj.Identity.AddressStreet"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="city" Label="Address City" @bind-Value="Obj.Identity.AddressCity"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="state" Label="Address State" @bind-Value="Obj.Identity.AddressState"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="zipcode" Label="Address Zip Code" @bind-Value="Obj.Identity.AddressZipCode"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="country" Label="Address Country" @bind-Value="Obj.Identity.AddressCountry"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="hobbies" Label="Hobbies" @bind-Value="Obj.Identity.Hobbies"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="phone-mobile" Label="Phone Mobile" @bind-Value="Obj.Identity.PhoneMobile"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="iban" Label="Bank Account IBAN" @bind-Value="Obj.Identity.BankAccountIBAN"></EditFormRow>
</div>
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800">Save Alias</button>
</div>
</div>
<button class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800" @onclick="SaveAlias">Save Alias</button>
</div>
</EditForm>
@if (IsSaving)
{
<p>Saving...</p>
}
@if (IsSaving)
{
<p>Saving...</p>
}
}
@@ -152,7 +157,7 @@ else
private bool EditMode { get; set; }
private bool Loading { get; set; } = true;
private Alias Obj { get; set; } = new();
private AliasEdit Obj { get; set; } = new();
private bool IsIdentityLoading { get; set; }
private bool IsSaving { get; set; }
@@ -211,15 +216,17 @@ else
return;
}
Obj = alias;
Obj = AliasToAliasEdit(alias);
}
else
{
// Create new Obj
Obj = new Alias();
Obj.Identity = new Shared.Models.WebApi.Identity();
Obj.Service = new Shared.Models.WebApi.Service();
Obj.Password = new Shared.Models.WebApi.Password();
var alias = new Alias();
alias.Identity = new Shared.Models.WebApi.Identity();
alias.Service = new Shared.Models.WebApi.Service();
alias.Password = new Shared.Models.WebApi.Password();
Obj = AliasToAliasEdit(alias);
}
// Hide loading spinner
@@ -248,7 +255,7 @@ else
Obj.Identity.AddressState = identity.Address.State;
Obj.Identity.AddressZipCode = identity.Address.ZipCode;
Obj.Identity.AddressCountry = identity.Address.Country;
Obj.Identity.Hobbies = identity.Hobbies.First();
Obj.Identity.Hobbies = identity.Hobbies[0];
Obj.Identity.EmailPrefix = identity.EmailPrefix;
Obj.Identity.PhoneMobile = identity.PhoneMobile;
Obj.Identity.BankAccountIBAN = identity.BankAccountIBAN;
@@ -285,7 +292,7 @@ else
{
try
{
DateTime.Parse(Obj.Identity.BirthDate);
DateTime.Parse(Obj.Identity.BirthDate, new CultureInfo("en-US"));
}
catch (Exception)
{
@@ -297,12 +304,12 @@ else
{
if (Id is not null)
{
Id = await AliasService.UpdateAliasAsync(Obj, Id.Value);
Id = await AliasService.UpdateAliasAsync(AliasEditToAlias(Obj), Id.Value);
}
}
else
{
Id = await AliasService.InsertAliasAsync(Obj);
Id = await AliasService.InsertAliasAsync(AliasEditToAlias(Obj));
}
IsSaving = false;
@@ -327,4 +334,33 @@ else
Navigation.NavigateTo("/alias/" + Id);
}
private AliasEdit AliasToAliasEdit(Alias alias)
{
return new AliasEdit
{
ServiceName = alias.Service.Name,
ServiceUrl = alias.Service.Url,
Password = alias.Password,
Identity = alias.Identity,
CreateDate = alias.CreateDate,
LastUpdate = alias.LastUpdate
};
}
private Alias AliasEditToAlias(AliasEdit alias)
{
return new Alias
{
Service = new Service
{
Name = alias.ServiceName,
Url = alias.ServiceUrl
},
Password = alias.Password,
Identity = alias.Identity,
CreateDate = alias.CreateDate,
LastUpdate = alias.LastUpdate
};
}
}

View File

@@ -75,5 +75,5 @@ public class EmailApiModel
/// <summary>
/// Gets or sets the list of attachments in the email.
/// </summary>
public List<AttachmentApiModel> Attachments { get; set; } = new();
public List<AttachmentApiModel> Attachments { get; set; } = [];
}

View File

@@ -25,5 +25,5 @@ public class MailboxApiModel
/// <summary>
/// Gets or sets the list of mailbox email API models.
/// </summary>
public List<MailboxEmailApiModel> Mails { get; set; } = new();
public List<MailboxEmailApiModel> Mails { get; set; } = [];
}

View File

@@ -155,7 +155,7 @@ else
return;
}
AliasEmail = Alias?.Identity.EmailPrefix + "@landmail.nl";
AliasEmail = Alias.Identity.EmailPrefix + "@landmail.nl";
IsLoading = false;
StateHasChanged();

View File

@@ -49,7 +49,7 @@ public class PageBase : OwningComponentBase
/// <summary>
/// Gets or sets the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
protected List<BreadcrumbItem> BreadcrumbItems { get; set; } = new List<BreadcrumbItem>();
protected List<BreadcrumbItem> BreadcrumbItems { get; set; } = [];
/// <summary>
/// Initializes the component asynchronously.

View File

@@ -36,6 +36,7 @@
private bool IsLoading { get; set; } = true;
private List<Shared.Models.WebApi.AliasListEntry> Aliases { get; set; } = new();
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)

View File

@@ -19,6 +19,16 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"http-release": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5067",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Release"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,

View File

@@ -22,8 +22,8 @@ public class AliasService(HttpClient httpClient)
/// <returns>Identity object.</returns>
public async Task<Identity> GenerateRandomIdentityAsync()
{
var identity = await httpClient.GetFromJsonAsync<Identity>("api/Identity/generate");
if (identity == null)
var identity = await httpClient.GetFromJsonAsync<Identity>("api/v1/Identity/generate");
if (identity is null)
{
throw new InvalidOperationException("Failed to generate random identity.");
}
@@ -40,7 +40,7 @@ public class AliasService(HttpClient httpClient)
{
try
{
var returnObject = await httpClient.PutAsJsonAsync<Alias>("api/Alias", aliasObject);
var returnObject = await httpClient.PutAsJsonAsync<Alias>("api/v1/Alias", aliasObject);
return await returnObject.Content.ReadFromJsonAsync<Guid>();
}
catch
@@ -59,7 +59,7 @@ public class AliasService(HttpClient httpClient)
{
try
{
var returnObject = await httpClient.PostAsJsonAsync<Alias>("api/Alias/" + id, aliasObject);
var returnObject = await httpClient.PostAsJsonAsync<Alias>("api/v1/Alias/" + id, aliasObject);
return await returnObject.Content.ReadFromJsonAsync<Guid>();
}
catch
@@ -77,7 +77,7 @@ public class AliasService(HttpClient httpClient)
{
try
{
return await httpClient.GetFromJsonAsync<Alias>("api/Alias/" + aliasId);
return await httpClient.GetFromJsonAsync<Alias>("api/v1/Alias/" + aliasId);
}
catch
{
@@ -93,7 +93,7 @@ public class AliasService(HttpClient httpClient)
{
try
{
return await httpClient.GetFromJsonAsync<List<AliasListEntry>>("api/Alias/items");
return await httpClient.GetFromJsonAsync<List<AliasListEntry>>("api/v1/Alias/items");
}
catch
{
@@ -111,7 +111,7 @@ public class AliasService(HttpClient httpClient)
// Delete from webapi.
try
{
await httpClient.DeleteAsync("api/Alias/" + id);
await httpClient.DeleteAsync("api/v1/Alias/" + id);
}
catch
{

View File

@@ -90,5 +90,14 @@ public class GlobalNotificationService
return messages;
}
/// <summary>
/// Clear all messages.
/// </summary>
public void ClearMessages()
{
SuccessMessages.Clear();
ErrorMessages.Clear();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}

View File

@@ -603,6 +603,14 @@ video {
right: 0px;
}
.top-12 {
top: 3rem;
}
.top-10 {
top: 2.5rem;
}
.z-10 {
z-index: 10;
}

View File

@@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AliasVault.WebApp</title>
<base href="/" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="css/tailwind.css" rel="stylesheet">
<link href="AliasVault.WebApp.styles.css" rel="stylesheet" />
</head>
<body class="bg-gray-50 dark:bg-gray-800">
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="js/dark-mode.js"></script>
<script>
window.initTopMenu = function() {
initDarkModeSwitcher();
};
window.clipboardCopy = {
copyText: function (text) {
navigator.clipboard.writeText(text).then(function () { })
.catch(function (error) {
alert(error);
});
}
};
</script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!--
==========================================================================================================
db 88 88 8b d8 88
d88b 88 "" `8b d8' 88 ,d
d8'`8b 88 `8b d8' 88 88
d8' `8b 88 88 ,adPPYYba, ,adPPYba, `8b d8' ,adPPYYba, 88 88 88 MM88MMM
d8YaaaaY8b 88 88 "" `Y8 I8[ "" `8b d8' "" `Y8 88 88 88 88
d8""""""""8b 88 88 ,adPPPPP88 `"Y8ba, `8b d8' ,adPPPPP88 88 88 88 88
d8' `8b 88 88 88, ,88 aa ]8I `888' 88, ,88 "8a, ,a88 88 88,
d8' `8b 88 88 `"8bbdP"Y8 `"YbbdP"' `8' `"8bbdP"Y8 `"YbbdP'Y8 88 "Y888
==========================================================================================================
AliasVault - Free open-source password manager.
Build (UTC): @BuildVersion
Source code: https://github.com/lanedirt/AliasVault
License: MIT
-->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AliasVault.WebApp</title>
<base href="/" />
<link rel="stylesheet" href="css/app.css?v=@CacheBuster" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="css/tailwind.css?v=@CacheBuster" rel="stylesheet">
<link href="AliasVault.WebApp.styles.css?v=@CacheBuster" rel="stylesheet" />
</head>
<body class="bg-gray-50 dark:bg-gray-800">
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="js/dark-mode.js?v=@CacheBuster"></script>
<script>
window.initTopMenu = function() {
initDarkModeSwitcher();
};
window.registerClickOutsideHandler = (dotNetHelper) => {
document.addEventListener('click', (event) => {
const menu = document.getElementById('userMenuDropdown');
const menuButton = document.getElementById('userMenuDropdownButton');
if (menu && !menu.contains(event.target) && !menuButton.contains(event.target)) {
dotNetHelper.invokeMethodAsync('CloseMenu');
}
const mobileMenu = document.getElementById('mobileMenu');
const mobileMenuButton = document.getElementById('toggleMobileMenuButton');
if (mobileMenu && !mobileMenu.contains(event.target) && !mobileMenuButton.contains(event.target)) {
dotNetHelper.invokeMethodAsync('CloseMenu');
}
});
};
window.clipboardCopy = {
copyText: function (text) {
navigator.clipboard.writeText(text).then(function () { })
.catch(function (error) {
alert(error);
});
}
};
</script>
<script src="_framework/blazor.webassembly.js?v=@CacheBuster"></script>
</body>
</html>

View File

@@ -1,5 +1,4 @@
function initDarkModeSwitcher() {
console.log('initDarkModeSwitcher');
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
@@ -16,6 +15,11 @@ function initDarkModeSwitcher() {
themeToggleDarkIcon.classList.remove('hidden');
}
}
else {
// Default to light mode if not set.
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
}
const themeToggleBtn = document.getElementById('theme-toggle');
@@ -35,16 +39,13 @@ function initDarkModeSwitcher() {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
// if NOT set via local storage previously
} else if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.dispatchEvent(event);

View File

@@ -77,7 +77,7 @@ public class BlazorWasmAppManager
}
}
private string GetBaseDirectory()
private static string GetBaseDirectory()
{
string currentDir = Directory.GetCurrentDirectory();
string baseDir = string.Empty;
@@ -90,35 +90,8 @@ public class BlazorWasmAppManager
return baseDir;
}
private async Task WaitForStartupAsync(int port)
{
// Wait for the application to start up
var started = false;
while (!started)
{
try
{
using (var client = new HttpClient())
{
var response = await client.GetAsync($"http://localhost:{port}");
started = response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound;
}
}
catch (Exception e)
{
if (_blazorWasmErrors.Count > 0)
{
Assert.Fail($"WASM failed to start: {string.Join(Environment.NewLine, _blazorWasmErrors)}");
return;
}
Console.WriteLine(e.Message);
}
}
}
#if WINDOWS
private void KillProcessAndChildrenWindows(int pid)
private static void KillProcessAndChildrenWindows(int pid)
{
var searcher = new ManagementObjectSearcher($"Select * From Win32_Process Where ParentProcessID={pid}");
var managementObjects = searcher.Get();
@@ -140,7 +113,7 @@ public class BlazorWasmAppManager
}
}
#else
private void KillProcessAndChildrenUnix(int pid)
private static void KillProcessAndChildrenUnix(int pid)
{
try
{
@@ -178,4 +151,31 @@ public class BlazorWasmAppManager
}
}
#endif
private async Task WaitForStartupAsync(int port)
{
// Wait for the application to start up
var started = false;
while (!started)
{
try
{
using (var client = new HttpClient())
{
var response = await client.GetAsync($"http://localhost:{port}");
started = response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound;
}
}
catch (Exception e)
{
await TestContext.Out.WriteLineAsync(e.Message);
if (_blazorWasmErrors.Count > 0)
{
Assert.Fail($"WASM failed to start: {string.Join(Environment.NewLine, _blazorWasmErrors)}");
return;
}
}
}
}
}

View File

@@ -33,6 +33,9 @@ public class WebApplicationFactoryFixture<TEntryPoint> : WebApplicationFactory<T
{
builder.UseUrls(HostUrl);
// Set the JWT key environment variable to debug value.
Environment.SetEnvironmentVariable("JWT_KEY", "12345678901234567890123456789012");
builder.ConfigureServices((context, services) =>
{
// Remove the existing AliasDbContext registration.

View File

@@ -27,6 +27,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.msbuild" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0"/>
<PackageReference Include="NUnit" Version="4.1.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0"/>

View File

@@ -13,6 +13,7 @@
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DocumentationFile>bin\Release\net8.0\Cryptography.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>